DEV Community

Anand Rathnas
Anand Rathnas

Posted on • Originally published at jo4.io

Implementing Per-Seat Team Billing with Stripe

This article was originally published on Jo4 Blog.

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).

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.

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

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.

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
    };
}
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

Testing Webhooks Locally

Use Stripe CLI:

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

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.

Top comments (0)