DEV Community

Cover image for Implementing Per-Seat Team Billing with Stripe
Anand Rathnas
Anand Rathnas

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
    };
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

Testing Webhooks Locally

Use Stripe CLI:

stripe listen --forward-to localhost:8080/api/v1/public/webhooks/stripe
Enter fullscreen mode Exit fullscreen mode

It gives you a webhook signing secret for local testing. Add it to your config:

app:
  stripe:
    webhook-secret: whsec_xxx
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

  1. Webhooks are truth - Don't trust client-side success callbacks. Always verify state via webhooks.
  2. Pause, don't punish - Give users time to fix payment issues before downgrading.
  3. Minimum seats = current members - Prevent invalid states where seats < active members.
  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 for easy lookup.
  6. Test the cancellation flow - It's the path most likely to have bugs. Test monthly/yearly, mid-cycle, end-of-cycle.
  7. 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)