Architecture Reference // 2026

Stripe
Integration

Production principles for webhooks, idempotency, subscriptions, and billing operations.
8 domains  ·  27 rules.

SUB
→
INV
→
PAY
→
SYN

SUBSCRIBE  ·  INVOICE  ·  PAY  ·  SYNC

01 // Data Model

What are the core entities and how do they relate?

Stripe's billing system is built on a precise entity hierarchy — but most integrations break because teams conflate what each entity represents. Product and Price are catalog definitions; Subscription and Invoice are runtime state; PaymentIntent is the payment collection mechanism. Confusing the catalog layer with the billing layer produces code that works in staging and breaks when you try to change pricing.

Prices are immutable — design for replacement, not editing
A Price object's amount, currency, interval, and billing_scheme are fixed at creation. There is no edit endpoint. When you change pricing, you create a new Price and migrate subscriptions to it. Give every Price a meaningful nickname at creation — your future self will need to distinguish price_monthly_v1 from price_monthly_v2 in the Stripe dashboard 18 months from now.
The Invoice is the central billing document — not the Subscription
A Subscription defines the recurring commitment and generates Invoices on its schedule. Each Invoice represents what is owed in a billing period. A PaymentIntent handles the payment collection attempt for that Invoice. The chain is Subscription → Invoice → PaymentIntent → Charge. This chain is prerequisite knowledge — every billing bug traces back to misunderstanding a step in it.
In B2B SaaS, create one Stripe Customer per organization — not per user
The billing relationship is with the organization. Attach all subscriptions, invoices, and payment methods to a single Customer per account. Creating a Customer per user produces subscription sprawl, makes seat-based billing require custom aggregation, and prevents invoice consolidation, account-level discounts, and shared payment methods from working as Stripe intended.
02 // Webhooks

How does your system learn what happened in Stripe?

Webhooks are not a convenience layer — they are the mechanism for billing state transitions. Three rules define a production-grade handler; most teams get two of the three right. An unverified, synchronous, ordering-dependent webhook handler is a liability disguised as an integration.

01
Verify every webhook signature — no exceptions
stripe.webhooks.constructEvent(payload, sig, secret) on every request, before any processing. A webhook endpoint without signature verification is a public HTTP endpoint that trusts any POST body claiming to be from Stripe. The signing secret is per-endpoint and must be stored as a secret, not in source code.
02
Return 2xx immediately — process in a background queue
If your handler takes more than a few seconds, Stripe will time out and retry. If it throws, Stripe retries with exponential backoff and a non-idempotent handler processes the same event multiple times. Enqueue the raw event payload on receipt, return 200, process asynchronously. This is not optional at any meaningful transaction volume.
03
Process idempotently by event ID — and assume out-of-order delivery
Stripe guarantees at-least-once delivery, not exactly-once. Store every processed event.id and skip duplicates. Do not assume causal ordering — a payment_intent.succeeded can arrive before the invoice.payment_succeeded for the same payment. Your handler must tolerate any permutation of event arrival.
04
Fetch the object from the API — don't trust the webhook payload alone
A webhook payload is a snapshot of the object at delivery time, not at event time. Delayed delivery means a stale snapshot. For high-stakes transitions — subscription activated, payment confirmed — use event.data.object.id to fetch the current object from the Stripe API before acting. This also eliminates exposure to replayed payloads with crafted state.
Critical events to handle
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded
invoice.payment_failed
invoice.finalized
payment_intent.payment_failed
invoice.payment_action_required
customer.subscription.trial_will_end
03 // Idempotency

How do you safely retry failed API calls?

Network failures, timeouts, and process crashes are unavoidable. When a Stripe API call fails mid-flight, you cannot know whether Stripe processed it. Without idempotency keys, retrying risks duplicate charges, duplicate subscriptions, and duplicate invoices. An idempotency key makes an uncertain retry safe — Stripe returns the cached response for any duplicate call with the same key within 24 hours.

Every mutating call gets a deterministic idempotency key — not a random one
Construct keys from your own business IDs: charge_order_<order_id>, sub_create_<account_id>_<price_id>. A randomly generated key at call time defeats the purpose entirely — you'll generate a new one on retry and Stripe will create a duplicate. The key must be the same on the original call and every retry of that same logical operation.
Persist the idempotency key to your database before calling the API
A key that lives only in memory is lost on process crash — and so is your ability to safely retry. Write the idempotency key and intended operation to your database as part of the transaction that initiates the Stripe call. On restart, retrieve the stored key and retry with it. Without this, a network timeout leaves you with an unknown outcome and no safe path forward.
Idempotency keys scope to a single logical operation
Creating a subscription and later upgrading it are two different operations with two different keys. Reusing a key across logically different calls causes Stripe to silently return the first operation's cached response for the second call. Stripe has no awareness of your business logic — it deduplicates by key alone. One operation, one key.
04 // Source of Truth

What does Stripe own versus what does your database own?

The most common architectural mistake in Stripe integrations is blurring this boundary — either mirroring all of Stripe's data into your database and fighting sync bugs indefinitely, or making live API calls on every authorization check and building fragile availability dependency. The boundary is precise: Stripe is authoritative for payment state; your database is authoritative for access and entitlement state.

Never derive access decisions from a live Stripe API call on the request path
"Is this account on a paid plan?" must be answerable from your database without a network call. Sync and cache the minimum subscription state at write time: subscription_status, current_period_end, stripe_customer_id, stripe_subscription_id. Keep this cache current via webhooks. Compute entitlements from your database — never from Stripe on every request.
Sync the minimum — resist duplicating Stripe's full data model
You need subscription status, period end, and payment method last-4 for your UI. You do not need to replicate every invoice line item, coupon history, or subscription schedule phase. Everything you sync is a new surface area for divergence. When you need full invoice history or payment details, fetch it from Stripe on demand — that is what the API is for.
A reconciliation job is the recovery mechanism for missed webhooks
Webhooks can be missed: your endpoint was down during a deploy, Stripe's retry window was exhausted, or a transient network partition dropped deliveries. A nightly job that fetches active subscriptions from Stripe's API and reconciles against your database closes this gap silently. It is not a replacement for event-driven sync — it is the correction layer for the failures that event-driven sync cannot prevent.
05 // Metadata

How do you correlate Stripe objects with your own system?

Every Stripe object — Customer, Subscription, Price, Invoice — accepts a metadata hash of up to 50 key-value pairs. Used correctly, metadata makes every Stripe record immediately actionable in your own system. Used incorrectly, metadata becomes an undocumented second database with no schema, no indexes, and no migration story.

Store your internal IDs on Stripe objects at creation — not the reverse
Set metadata: { account_id: "acc_123", plan_slug: "growth" } on every Customer and Subscription. This makes the Stripe dashboard immediately actionable: any customer record shows your internal ID, enabling direct navigation to the account. It also enables reconstruction of your internal state from a Stripe export in the event of a database incident — a non-trivial property.
Metadata is not a query index — Stripe does not search it efficiently
You cannot run "fetch all customers where metadata.plan_tier = enterprise" without paginating your entire customer list sequentially. If you need to query by a metadata value, maintain that index in your own database. Stripe metadata is for display and cross-reference. Your database is for query.
50 keys, 40-char key names, 500-char values — work within the limits
Do not store JSON blobs, serialized objects, configuration, or audit trails in metadata. Keep values to human-readable strings and cross-reference IDs. A metadata value that requires parsing is a strong signal the data belongs in your database, not on the Stripe object. Approaching the 50-key limit on a single object is a design smell.
06 // Subscriptions & Pricing

How do you model pricing and handle subscription changes?

Stripe's subscription model is opinionated because recurring billing at scale has real constraints. Most integration pain comes from fighting the model rather than working with it. The constraints are not arbitrary — they reflect edge cases that will occur at production volume. The single most consequential constraint: a Price is immutable once created. Your entire pricing architecture must account for this from day one.

01
Separate Product, Price, and Subscription intentionally at design time
One Product (Pro Plan) should have multiple Prices (monthly, annual, legacy-grandfathered, promotional). A Subscription points to a specific Price, not the Product. When you need to migrate a customer to new pricing, you update the Subscription's items[].price to the new Price ID and Stripe handles proration. If you created one Price per customer at signup, this migration becomes a manual loop over your entire customer base.
02
Use Subscription Schedules for future pricing changes — not webhook-triggered mutations
If you need to change a customer's price at their next renewal, or add a trial phase to an existing subscription, use subscription_schedules. A Schedule is Stripe-side state that executes on its configured date regardless of whether your servers are available. A webhook handler that fires at renewal and calls the Stripe API to update the subscription is fragile, produces race conditions with invoice finalization, and silently fails when your service is down.
03
Metered billing requires usage records before invoice finalization — with no retroactive correction
If you use usage_type: metered, submit usage records via subscriptionItems.createUsageRecord() before Stripe finalizes the invoice. Finalization is irreversible — you cannot add or amend usage records on a finalized invoice. Usage submission must be on the critical path of the billable activity. A batch job that runs at month-end is a billing error waiting to happen.
04
Set proration_behavior explicitly — never rely on the default
When a subscription is upgraded, downgraded, or its quantity changes mid-cycle, Stripe prorates by default. The default may not match your pricing commitments. Use proration_behavior: 'none' when changes take effect at next renewal. Use 'create_prorations' for immediate charges. Test what your customers will actually be charged on every subscription change path before shipping — not after your first support ticket.
07 // One-offs & Invoice Items

How do you charge for things outside the subscription?

Not every charge belongs in a subscription. One-time fees, usage overages, manual adjustments, and add-ons each have the right Stripe primitive. Choosing the wrong one produces the wrong customer experience, the wrong accounting record, and often the wrong charge. The primary question: should this appear on the customer's next subscription invoice, or be collected immediately as a separate payment?

Pending invoice items are swept into the next subscription invoice automatically
Call stripe.invoiceItems.create({ customer, amount, currency, description }) without a subscription parameter. Stripe attaches the item to the customer's next upcoming invoice. This is the correct mechanism for setup fees, overage charges, one-time add-ons, and manual credits that should appear alongside the regular subscription line — not as a separate payment request that confuses the customer.
PaymentIntents are for immediate, session-present one-time charges
A PaymentIntent is the right primitive for checkout flows, one-time purchases, and top-ups where the customer is present in a UI. It handles SCA/3DS authentication natively, tracks the full payment lifecycle from requires_payment_method through succeeded, and supports every payment method type. Do not use it for charges that should appear on the subscription invoice.
Off-session payments require explicit handling of requires_action
For any payment initiated without the customer present — renewal, usage billing, manual invoice — use confirm: true and off_session: true with a saved payment_method. Stripe may return requires_action if the bank requires additional authentication. If you ignore this status, the payment silently fails. Handle payment_intent.payment_failed and invoice.payment_action_required webhooks and surface the required action to the customer via email.
08 // Coupons, Discounts & Grace Periods

How do you handle pricing exceptions and failed payments gracefully?

Stripe's discount system and dunning behavior are more nuanced than they appear. Coupons, promotion codes, and discounts are three distinct objects with different lifetimes, application rules, and revocation semantics. Grace periods after payment failure are configured in your Dashboard retry settings — not in code. Most teams discover these distinctions under pressure, during a pricing incident or a customer churn event — not before one.

Understand the three-layer model: Coupon → Promotion Code → Discount
A Coupon is the definition: 30% off, valid for 3 months, max 100 redemptions. A Promotion Code is the human-readable redemption string (LAUNCH30) that points to a Coupon. A Discount is the live application of a Coupon to a specific Customer or Subscription at a specific timestamp. Deleting a Coupon invalidates future redemptions but does not remove active Discounts already applied. Revoking a Discount ends it immediately and generates a prorated credit on the next invoice.
Coupon duration semantics are exact — use them deliberately
once applies only to the next invoice. repeating with duration_in_months: 3 applies for exactly 3 billing cycles from the application date. forever applies until revoked or the subscription ends. A "first 3 months 50% off" offer is repeating with duration_in_months: 3 — not forever plus a webhook that removes the discount at month 3. The webhook approach will silently race with invoice finalization.
Grace periods after failed payment live in Dashboard retry settings — not in your code
Stripe retries failed invoices on a schedule you configure: Smart Retries or a manual schedule. During the retry window, the subscription moves to past_due but remains active. Your code must allow past_due customers continued access — not revoke it on the first failure. Access revocation belongs on customer.subscription.deleted (status canceled), which fires only after all retries are exhausted. Triggering revocation on past_due churns customers whose cards are recoverable.
Build a dunning communication layer on top of Stripe's retry events
Stripe handles payment retry. You own customer communication. Listen for invoice.payment_failed to send the first card-update notification. Listen for customer.subscription.updated to detect past_due → canceled transitions and send a final notice. Send progressively urgent messages during the retry window. A customer who doesn't know their card failed cannot update it — silent dunning is a preventable churn event.

The mental model

Stripe owns payment state. Your database owns access state. Never cross the boundary.
Every mutation gets an idempotency key. Every webhook is processed idempotently.
Prices are immutable. Design for replacement from day one.
Grace periods are retry settings. Revoke access on canceled — not past_due.
A webhook without signature verification is a public endpoint. Treat it as such.

Daniel Brasileiro