Stripe Integration for SaaS Billing: Webhooks, Plans and API Keys
Building a SaaS product means building a billing system. For API-based services like document generation platforms, billing is tightly coupled with access control: the plan a customer subscribes to determines their API key rate limits, available features, and usage quotas. Stripe is the standard choice for SaaS billing, but integrating it correctly requires understanding the event-driven architecture that ties checkout, subscriptions, and your internal systems together.
This article covers the complete integration flow we use at Doxnex: Stripe Checkout for payment collection, webhook handling for subscription lifecycle events, and the bridge between billing plans and API key provisioning.
Architecture Overview
The billing system has four components that communicate through events:
- Stripe Checkout — Hosted payment page where customers enter card details and subscribe
- Stripe Webhooks — Events sent from Stripe to your server when subscription state changes
- Plan Registry — Internal mapping between Stripe price IDs and your application's feature flags and rate limits
- API Key Manager — Service that provisions, updates, and revokes API keys based on billing state
The critical principle is that Stripe is the source of truth for billing state. Your application should never modify subscription data directly. Instead, it initiates actions (create checkout session, create portal session) and reacts to webhook events.
Setting Up Stripe Checkout
Stripe Checkout is a hosted payment page that handles PCI compliance, 3D Secure authentication, and supports dozens of payment methods. You create a checkout session on your server and redirect the customer to the Stripe-hosted page.
@PostMapping("/api/billing/checkout")
public ResponseEntity<Map<String, String>> createCheckout(
@AuthenticationPrincipal User user,
@RequestBody CheckoutRequest request) {
SessionCreateParams params = SessionCreateParams.builder()
.setMode(SessionCreateParams.Mode.SUBSCRIPTION)
.setCustomerEmail(user.getEmail())
.setSuccessUrl("https://app.doxnex.io/billing?success=true")
.setCancelUrl("https://app.doxnex.io/billing?canceled=true")
.addLineItem(SessionCreateParams.LineItem.builder()
.setPrice(request.getPriceId())
.setQuantity(1L)
.build())
.putMetadata("user_id", user.getId().toString())
.build();
Session session = Session.create(params);
return ResponseEntity.ok(Map.of("url", session.getUrl()));
}
The metadata field is essential. It lets you attach your internal user ID to the checkout session, which you will need when processing webhooks to associate the Stripe customer with your application user.
Webhook Handling: The Heart of the Integration
Webhooks are where most Stripe integrations break. The common pitfalls are: not verifying signatures, not handling idempotency, and doing too much work synchronously in the handler.
Signature Verification
@PostMapping("/webhooks/stripe")
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String signature) {
Event event;
try {
event = Webhook.constructEvent(
payload, signature, webhookSecret);
} catch (SignatureVerificationException e) {
return ResponseEntity.status(400).body("Invalid signature");
}
// Check idempotency
if (processedEvents.contains(event.getId())) {
return ResponseEntity.ok("Already processed");
}
// Queue for async processing
eventQueue.publish(event);
processedEvents.add(event.getId());
return ResponseEntity.ok("Received");
}
Critical Webhook Events
You need to handle these events at minimum for a subscription-based SaaS:
checkout.session.completed— Customer completed payment. Create the Stripe customer association, provision API keys, set initial rate limits.customer.subscription.updated— Plan changed (upgrade or downgrade). Update rate limits and feature flags.customer.subscription.deleted— Subscription canceled. Revoke API keys or switch to free tier.invoice.payment_failed— Payment failed. Send notification, enter grace period. Do not immediately revoke access.invoice.paid— Payment succeeded after a retry. Clear any payment failure flags.
Plan Registry: Connecting Prices to Features
Your internal plan configuration should be decoupled from Stripe price IDs, connected by a mapping table. This lets you change Stripe pricing without modifying application logic.
plan_registry:
- stripe_price_id: "price_1Abc123"
tier: "starter"
rate_limit_per_minute: 60
documents_per_month: 1000
features: ["pdf", "png"]
- stripe_price_id: "price_1Def456"
tier: "business"
rate_limit_per_minute: 300
documents_per_month: 10000
features: ["pdf", "png", "docx", "custom_fonts"]
- stripe_price_id: "price_1Ghi789"
tier: "enterprise"
rate_limit_per_minute: 1000
documents_per_month: -1 # unlimited
features: ["pdf", "png", "docx", "custom_fonts", "webhooks", "priority_support"]
API Key Provisioning on Subscription Events
When a checkout.session.completed event arrives, the system must create a new API key for the customer with the correct rate limits. The flow is:
- Extract
user_idfrom the session metadata - Look up the subscribed price ID in the plan registry
- Generate a new API key with the plan's rate limits
- Store the SHA-256 hash in the database
- Send the raw key to the customer via email (only time it is shown)
On subscription updates, the system modifies the rate limits associated with existing API keys rather than issuing new ones. This avoids forcing customers to update their integrations when they upgrade or downgrade.
Handling Edge Cases
Payment Failures and Grace Periods
When invoice.payment_failed fires, do not immediately cut off access. Implement a grace period (typically 7-14 days) during which the customer retains full access while Stripe retries the payment. Send escalating email notifications. Only downgrade to a restricted state if all retries fail and the subscription is ultimately canceled.
Subscription Upgrades vs. Downgrades
Upgrades should take effect immediately: the customer is paying more and expects instant access to the new features. Downgrades should take effect at the end of the billing cycle: the customer has already paid for the current period. Stripe handles the prorating; your application handles the timing of feature changes.
Free Tier Fallback
When a subscription ends, do not delete the customer's account or API keys. Instead, transition them to a free tier with restricted rate limits. This keeps the door open for re-subscription and avoids breaking any existing integrations.
Testing the Integration
Stripe provides a comprehensive test mode with test card numbers, test webhooks, and a CLI tool for forwarding events to your local development server.
# Forward Stripe test webhooks to local server
stripe listen --forward-to localhost:8080/webhooks/stripe
# Trigger a specific event for testing
stripe trigger checkout.session.completed
Always test the complete cycle: checkout, subscription creation, upgrade, downgrade, payment failure, and cancellation. Each event should produce the correct state change in your API key management system.
At Doxnex, our Stripe integration handles the full subscription lifecycle automatically. From the moment a customer completes checkout to their first API call, the entire provisioning pipeline runs without manual intervention.
Frequently Asked Questions
How do I handle failed Stripe webhook deliveries?
Stripe retries failed webhooks with exponential backoff for up to 3 days. Design your webhook handler to be idempotent by checking the event ID against a processed events table before taking action. Always return a 200 status quickly and process the event asynchronously to avoid timeouts.
Should I use Stripe Checkout or build a custom payment form?
Use Stripe Checkout for most SaaS applications. It handles PCI compliance, supports dozens of payment methods, manages 3D Secure authentication, and is maintained by Stripe. A custom form with Stripe Elements only makes sense if you need deep UI customization that Checkout cannot provide.
How do I connect Stripe subscriptions to API key rate limits?
Store a mapping between Stripe price IDs and your internal plan tiers. When a checkout.session.completed or customer.subscription.updated webhook arrives, look up the price ID, determine the plan tier, and update the rate limits associated with the customer's API keys in your database. This decouples billing from access control while keeping them synchronized.
How do I handle subscription downgrades?
When a customer downgrades, apply the new rate limits at the end of the current billing period, not immediately. Stripe sends a customer.subscription.updated event with the new plan effective date. Store this pending change and apply it when the billing period rolls over. This prevents disrupting the customer's service mid-cycle.