Stripe Integration for SaaS Billing: Webhooks, Plans and API Keys

Published March 22, 2026 · 9 min read · Doxnex Engineering

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:

  1. Stripe Checkout — Hosted payment page where customers enter card details and subscribe
  2. Stripe Webhooks — Events sent from Stripe to your server when subscription state changes
  3. Plan Registry — Internal mapping between Stripe price IDs and your application's feature flags and rate limits
  4. 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:

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:

  1. Extract user_id from the session metadata
  2. Look up the subscribed price ID in the plan registry
  3. Generate a new API key with the plan's rate limits
  4. Store the SHA-256 hash in the database
  5. 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.