Implementing Per-Seat Team Billing with Stripe

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

  1. Webhooks are truth - Don't trust client-side success callbacks
  2. Pause, don't punish - Give users time to fix payment issues before downgrading
  3. Minimum seats = current members - Prevent invalid states
  4. Email on failures - Users need to know immediately when payment fails
  5. Metadata is your friend - Store teamId and tier in Stripe subscription metadata
  6. 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.