Adding team plans to a SaaS is tricky. You need per-seat pricing, plan upgrades/downgrades, webhook handling, and graceful degradation when payments fail. Here's how I built it.
The Pricing Model
We offer both individual and team plans:
Individual Plans:
Free: $0/month - 30 URLs, 2 bio links, basic analytics
Pro: $16/month - 500 URLs, 10 bio links, custom domains
Pro Plus: $48/month - Unlimited everything, white-label, SSO
Team Plans (per-seat):
Team Pro: $10/seat/month - 1,000 URLs/seat, 10 team members max
Team Business: $20/seat/month - 2,000 URLs/seat, 50 team members, priority support
Annual billing gets 2 months free (pay for 10, get 12).
Creating Checkout Sessions
public String createCheckoutSession(String teamSlug, SubscriptionTier tier,
int seatCount, SubscriptionInterval interval,
Long userId) {
TeamEntity team = getTeamOrThrow(teamSlug);
TeamMemberEntity member = getMemberOrThrow(team.getId(), userId);
// Only owner can manage billing
if (!member.canManageBilling()) {
throw new AppException(ErrorCode.TEAM_BILLING_NOT_OWNER);
}
// Minimum seats = current active members
int activeMembers = countActiveFullMembers(team.getId());
int effectiveSeatCount = Math.max(seatCount, activeMembers);
String priceId = getPriceIdForTier(tier, interval);
// Create or get Stripe customer
String customerId = getOrCreateStripeCustomer(team, member);
SessionCreateParams params = SessionCreateParams.builder()
.setCustomer(customerId)
.setMode(SessionCreateParams.Mode.SUBSCRIPTION)
.setSuccessUrl(baseUrl + "/teams/" + teamSlug + "/settings?success=true")
.setCancelUrl(baseUrl + "/teams/" + teamSlug + "/settings?canceled=true")
.addLineItem(SessionCreateParams.LineItem.builder()
.setPrice(priceId)
.setQuantity((long) effectiveSeatCount)
.build())
.setSubscriptionData(SessionCreateParams.SubscriptionData.builder()
.putMetadata("teamId", team.getId().toString())
.putMetadata("tier", tier.getValue())
.build())
.build();
return Session.create(params).getUrl();
}
The Seat Count Problem
Users will try to downgrade seats below their member count:
int activeMembers = countActiveFullMembers(team.getId());
int effectiveSeatCount = Math.max(seatCount, activeMembers);
If they have 5 team members but try to buy 3 seats, we auto-correct to 5. The UI should warn them first.
Handling Webhooks
Stripe webhooks are the source of truth. Never trust client-side success callbacks.
@PostMapping("/webhooks/stripe")
public ResponseEntity<String> handleWebhook(@RequestBody String payload,
@RequestHeader("Stripe-Signature") String signature) {
Event event = Webhook.constructEvent(payload, signature, webhookSecret);
switch (event.getType()) {
case "checkout.session.completed" -> handleCheckoutCompleted(event);
case "customer.subscription.updated" -> handleSubscriptionUpdated(event);
case "customer.subscription.deleted" -> handleSubscriptionCancelled(event);
case "invoice.payment_failed" -> handlePaymentFailed(event);
}
return ResponseEntity.ok("OK");
}
Payment Failed - Pause, Don't Delete
void handlePaymentFailed(Event event) {
Invoice invoice = (Invoice) event.getDataObjectDeserializer()
.getObject().orElseThrow();
String subscriptionId = invoice.getSubscription();
TeamEntity team = teamRepository.findByStripeSubscriptionId(subscriptionId)
.orElse(null);
if (team == null) return;
// Pause the team - don't delete data
team.setSubscriptionStatus(SubscriptionStatus.PAST_DUE);
teamRepository.save(team);
// Email the owner
sendPaymentFailedEmail(team);
}
Why pause instead of downgrade immediately? Give users time to fix payment. Nobody wants to lose features because their card expired.
URL Limits Based on Seats
Team URL limits scale with seat count:
private int calculateUrlLimit(String tier, int seatCount) {
return switch (tier.toUpperCase()) {
case "TEAM_PRO" -> seatCount * 1000; // 1,000 URLs per seat
case "TEAM_BUSINESS" -> seatCount * 2000; // 2,000 URLs per seat
case "PRO_PLUS" -> Integer.MAX_VALUE; // Unlimited
default -> FREE_TEAM_URL_LIMIT; // Free tier: 100 URLs
};
}
Billing Portal Access
Let users manage their subscription in Stripe's portal:
public String createBillingPortalSession(String teamSlug, Long userId) {
TeamEntity team = getTeamOrThrow(teamSlug);
if (team.getStripeCustomerId() == null) {
throw new AppException(ErrorCode.TEAM_NO_SUBSCRIPTION);
}
SessionCreateParams params = SessionCreateParams.builder()
.setCustomer(team.getStripeCustomerId())
.setReturnUrl(baseUrl + "/teams/" + teamSlug + "/settings")
.build();
return Session.create(params).getUrl();
}
Testing Webhooks Locally
Use Stripe CLI:
stripe listen --forward-to localhost:8080/api/v1/public/webhooks/stripe
Lessons Learned
- Webhooks are truth - Don't trust client-side success callbacks
- Pause, don't punish - Give users time to fix payment issues before downgrading
- Minimum seats = current members - Prevent invalid states
- Email on failures - Users need to know immediately when payment fails
- Metadata is your friend - Store teamId and tier in Stripe subscription metadata
- Test the cancellation flow - It's the path most likely to have bugs
Building team billing? What edge cases have bitten you?
Building jo4.io - URL shortener with analytics, bio pages, and team workspaces.