Production principles for webhooks, idempotency, subscriptions, and billing operations.
8 domains · 27 rules.
SUBSCRIBE · INVOICE · PAY · SYNC
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.
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.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.
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.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.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.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.
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.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.
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.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.
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.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.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.
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.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.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.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.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?
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.requires_payment_method through succeeded, and supports every payment method type. Do not use it for charges that should appear on the subscription invoice.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.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.
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.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.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.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.
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.