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). For example:
- Team Pro annual: $100/seat/year (saves $20/seat)
- Team Business annual: $200/seat/year (saves $40/seat)
Stripe Product Setup
In Stripe Dashboard, create products with monthly and yearly prices:
| Product | Monthly Price | Yearly Price | Billing Type |
|---|---|---|---|
pro |
$16/month | $160/year | Per-user |
pro-plus |
$48/month | $480/year | Per-user |
team-pro |
$10/seat/month | $100/seat/year | Per-seat (metered) |
team-business |
$20/seat/month | $200/seat/year | Per-seat (metered) |
Store the price IDs in your config:
app:
stripe:
# Individual plans
pro-price-id: price_pro_monthly
pro-yearly-price-id: price_pro_yearly
pro-plus-price-id: price_proplus_monthly
pro-plus-yearly-price-id: price_proplus_yearly
# Team plans (per-seat)
team-pro-price-id: price_teampro_monthly
team-pro-yearly-price-id: price_teampro_yearly
team-business-price-id: price_teambiz_monthly
team-business-yearly-price-id: price_teambiz_yearly
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 and prompt to remove members before downgrading.
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");
}
Checkout Completed
void handleCheckoutCompleted(Event event) {
Session session = (Session) event.getDataObjectDeserializer()
.getObject().orElseThrow();
String teamId = session.getSubscriptionDetails().getMetadata().get("teamId");
String tier = session.getSubscriptionDetails().getMetadata().get("tier");
String subscriptionId = session.getSubscription();
TeamEntity team = teamRepository.findById(Long.parseLong(teamId)).orElseThrow();
team.setStripeSubscriptionId(subscriptionId);
team.setSubscriptionTier(SubscriptionTier.fromValue(tier));
team.setSubscriptionStatus(SubscriptionStatus.ACTIVE);
team.setPaidSeatCount(session.getQuantity().intValue());
team.setUrlLimit(calculateUrlLimit(tier, session.getQuantity().intValue()));
teamRepository.save(team);
}
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.
Subscription Cancelled - Downgrade Gracefully
void handleSubscriptionCancelled(Event event) {
Subscription subscription = (Subscription) event.getDataObjectDeserializer()
.getObject().orElseThrow();
TeamEntity team = teamRepository.findByStripeSubscriptionId(subscription.getId())
.orElse(null);
if (team == null) return;
// Downgrade to free tier
team.setSubscriptionTier(SubscriptionTier.FREE);
team.setSubscriptionStatus(SubscriptionStatus.NONE);
team.setStripeSubscriptionId(null);
team.setPaidSeatCount(null);
team.setUrlLimit(FREE_TEAM_URL_LIMIT);
teamRepository.save(team);
sendSubscriptionCancelledEmail(team);
}
URL Limits Based on Seats
Team URL limits scale with seat count:
private static final int FREE_TEAM_URL_LIMIT = 100;
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
};
}
When seats change via Stripe portal, the webhook updates the limit:
void handleSubscriptionUpdated(Event event) {
Subscription subscription = (Subscription) event.getDataObjectDeserializer()
.getObject().orElseThrow();
TeamEntity team = teamRepository.findByStripeSubscriptionId(subscription.getId())
.orElse(null);
if (team == null) return;
int newSeatCount = subscription.getItems().getData().get(0)
.getQuantity().intValue();
team.setPaidSeatCount(newSeatCount);
team.setUrlLimit(calculateUrlLimit(team.getSubscriptionTier().getValue(), newSeatCount));
teamRepository.save(team);
}
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();
}
Email Notifications
Payment failures need immediate user notification:
private void sendPaymentFailedEmail(TeamEntity team) {
UserEntity owner = userRepository.findById(team.getOwnerId()).orElse(null);
if (owner == null || owner.getEmail() == null) return;
String html = """
<h2>Payment Failed for %s</h2>
<p>We couldn't process your payment. Please update your payment
method to avoid service interruption.</p>
<a href="%s/teams/%s/settings">Update Payment Method</a>
""".formatted(team.getName(), baseUrl, team.getTeamSlug());
emailService.sendHtmlEmailAsync(
owner.getEmail(),
"Payment Failed - " + team.getName(),
html
);
}
Testing Webhooks Locally
Use Stripe CLI:
stripe listen --forward-to localhost:8080/api/v1/public/webhooks/stripe
It gives you a webhook signing secret for local testing. Add it to your config:
app:
stripe:
webhook-secret: whsec_xxx
Lessons Learned
- Webhooks are truth - Don't trust client-side success callbacks. Always verify state via webhooks.
- Pause, don't punish - Give users time to fix payment issues before downgrading.
- Minimum seats = current members - Prevent invalid states where seats < active members.
- Email on failures - Users need to know immediately when payment fails.
- Metadata is your friend - Store teamId and tier in Stripe subscription metadata for easy lookup.
- Test the cancellation flow - It's the path most likely to have bugs. Test monthly/yearly, mid-cycle, end-of-cycle.
- Handle quantity changes - Users can change seat count in Stripe portal. Your webhook must handle it.
Building team billing? What edge cases have bitten you? Drop a comment below.
Building jo4.io - URL shortener with analytics, bio pages, and team workspaces.
Top comments (0)