An exhaustive, production-grade masterclass from absolute zero to staff-level Stripe payments architecture.
Master Checkout and Payment Intents, signature-verified webhooks, idempotent fulfillment, subscriptions and billing, refunds and disputes, Connect marketplaces, and disciplined test-mode workflows.
Getting Started β’ Checkout β’ Payment Intents β’ Webhooks β’ Subscriptions & Refunds β’ Connect & Test Mode β’ Staff Handbook
flowchart LR
S1["Stage 1: Payments Mental Model & Keys"] --> S2["Stage 2: Hosted Checkout"]
S2 --> S3["Stage 3: Payment Intents & SCA"]
S3 --> S4["Stage 4: Webhooks & Signatures"]
S4 --> S5["Stage 5: Subscriptions, Refunds & Disputes"]
S5 --> S6["Stage 6: Connect, Test Mode & Operations"]
S6 --> S7["Stage 7: Staff Payments Architect Handbook & Cheatsheet"]
| Stage | Domain Focus | Core Concepts & Production Outcomes |
|---|---|---|
| Stage 1 | Payments Foundations & Object Model | Card network flow, test vs live mode, pk_/sk_/rk_ keys, PaymentIntent, Customer, Price, Event, minor-unit integer amounts, error types and request IDs. |
| Stage 2 | Stripe Checkout & Hosted Flows | checkout.sessions.create, server-side catalogs, client_reference_id, success URLs with {CHECKOUT_SESSION_ID}, idempotency keys, embedded Checkout and Payment Links. |
| Stage 3 | Payment Intents & Custom Flows | The PaymentIntent state machine, Payment Element, requires_action and 3D Secure, manual capture, SetupIntent, off-session charges and authentication_required recovery. |
| Stage 4 | Webhooks & Event-Driven Fulfillment | Stripe-Signature HMAC-SHA256 scheme, replay tolerance, raw-body parsing in Express, at-least-once delivery, event deduplication, out-of-order handling, Stripe CLI forwarding. |
| Stage 5 | Subscriptions, Billing, Refunds & Disputes | Recurring Price objects, subscription statuses, invoice.paid vs invoice.payment_failed, Smart Retries, Customer Portal, proration, partial refunds, dispute evidence. |
| Stage 6 | Connect, Test Mode & Production Ops | Destination vs direct charges, application_fee_amount, Account Links onboarding, test cards, sandboxes, test clocks, restricted keys, PCI scope, Radar, Docker deployment. |
| Stage 7 | Staff Payments Architect Handbook | 40 staff-level scenario Q&As, double-charge prevention, reconciliation, webhook outages, marketplace liability, and an enterprise command cheatsheet. |
- 1. Stage 1: Absolute Beginner Foundations & the Payments Mental Model
- 2. Stage 2: Stripe Checkout & Hosted Payment Flows
- 3. Stage 3: Payment Intents, SCA & Custom Payment Flows
- 4. Stage 4: Webhooks, Signatures & Event-Driven Fulfillment
- 5. Stage 5: Subscriptions, Billing, Refunds & Disputes
- 6. Stage 6: Connect, Test-Mode Workflows & Production Operations
- 7. Stage 7: Staff Payments Architect Interview Handbook & Cheatsheet
- π€ Contributing
- π License
Stripe is a payments platform exposed as a REST API. You send it HTTPS requests with a secret key; it moves money across card networks, bank rails and wallets on your behalf, and it tells you what happened through API responses and asynchronous events. Everything in this guide follows from one idea: your server decides what to charge, Stripe decides whether the charge worked, and webhooks carry that decision back to you.
A single "Pay" click touches five parties. Understanding who holds the money at each step explains most of the API design.
sequenceDiagram
participant C as Customer (browser)
participant M as Your Server
participant S as Stripe
participant N as Card Network (Visa / Mastercard)
participant I as Issuing Bank
C->>M: Checkout request (SKU, quantity)
M->>S: Create Checkout Session / PaymentIntent (secret key)
S-->>M: Session URL or client_secret
C->>S: Card details (never touch your server)
S->>N: Authorisation request
N->>I: Route to issuer
I-->>N: Approve / decline / challenge (3DS)
N-->>S: Result
S-->>C: Redirect or confirmation
S-)M: Webhook event (signed)
Note over S,M: Funds settle to your Stripe balance, then pay out to your bank
- Authorisation: the issuer checks funds and fraud signals and places a hold. Nothing has moved yet.
- Capture: the merchant claims the held funds. Stripe captures immediately by default.
- Clearing & settlement: the network moves funds from issuer to Stripe over the following days.
- Payout: Stripe transfers your available balance, minus fees, to your bank account on a schedule.
- Reversal paths: refunds (merchant-initiated) and disputes (cardholder-initiated through the issuer) send money backwards.
Bank debits (ACH Direct Debit, SEPA Direct Debit, BACS) are slower: the payment sits in processing for days and can still fail after the customer has left your site. This single fact is why fulfillment must be event-driven.
Every Stripe account has two fully separate data planes: test mode and live mode. Objects created in one never appear in the other, and each mode has its own keys and webhook endpoints. Stripe also offers sandboxes, isolated test environments you can create per developer, branch or CI pipeline so that teams stop trampling each other's test data.
| Key Prefix | Where It Lives | What It Can Do |
|---|---|---|
pk_test_ / pk_live_ |
Browser and mobile apps | Tokenise payment details, confirm intents with a client_secret. Safe to publish. |
sk_test_ / sk_live_ |
Server only (secrets manager) | Full read/write access to the account. Treat as a root credential. |
rk_test_ / rk_live_ |
Server, scoped services | Restricted key with per-resource permissions (e.g. write Checkout Sessions, read nothing else). |
whsec_ |
Server only | Webhook endpoint signing secret, unique per endpoint. |
Production rules that save careers:
- Never ship a secret key to a client. If a
sk_live_key appears in a bundle, roll it in the Dashboard immediately. - Prefer restricted keys for every service that does not need full access. A reporting job needs read-only; a checkout service needs Checkout Sessions write.
- Fail closed on live keys outside production. The companion service refuses
sk_live_unlessSTRIPE_ALLOW_LIVE=trueis set deliberately.
// src/config.ts (excerpt): refuse live keys unless production opts in
const TEST_KEY = /^(sk|rk)_test_/;
const LIVE_KEY = /^(sk|rk)_live_/;
if (LIVE_KEY.test(stripeSecretKey) && env.STRIPE_ALLOW_LIVE !== 'true') {
throw new ConfigError('Live key detected. Set STRIPE_ALLOW_LIVE=true only in production.');
}graph TD
subgraph Catalog
P[Product] --> PR[Price]
end
subgraph Payments
CS[Checkout Session] --> PI[PaymentIntent]
PI --> CH[Charge]
PI --> PM[PaymentMethod]
CH --> RF[Refund]
CH --> DP[Dispute]
end
subgraph Billing
CU[Customer] --> SUB[Subscription]
SUB --> INV[Invoice]
INV --> PI
end
CU --> PM
PR --> CS
PR --> SUB
EV[Event] -.describes changes to.-> PI
EV -.-> SUB
EV -.-> CS
| Object | ID Prefix | Role |
|---|---|---|
| Customer | cus_ |
A payer you recognise again: email, saved payment methods, invoices. |
| Product / Price | prod_ / price_ |
What you sell and how much it costs (one-time or recurring). A product has many prices. |
| PaymentIntent | pi_ |
One attempt to collect a specific amount, tracked through authentication and capture. The heart of modern Stripe. |
| PaymentMethod | pm_ |
A card, bank account or wallet credential, reusable when attached to a customer. |
| Charge | ch_ |
The record of an individual money movement created by a PaymentIntent. Refunds and disputes hang off it. |
| Checkout Session | cs_ |
A hosted payment page that creates the PaymentIntent (or Subscription) for you. |
| SetupIntent | seti_ |
Collects and authenticates a payment method for later use without charging now. |
| Subscription / Invoice | sub_ / in_ |
Recurring billing; each cycle produces an invoice, which produces a PaymentIntent. |
| Event | evt_ |
An immutable record that something changed. Delivered to webhooks and retained for listing. |
Stripe amounts are integers in the currency's smallest unit. Floating point never appears on the wire, and it should never appear in your pricing code either.
| Currency Class | Examples | unit_amount for "10" |
|---|---|---|
| Two-decimal (most) | usd, eur, gbp, inr |
1000 |
| Zero-decimal | jpy, krw, vnd |
10 |
| Three-decimal | kwd, bhd, jod |
10000 (Stripe may require the last digit to be 0) |
Each currency also has a minimum chargeable amount (roughly the equivalent of fifty US cents), so a five-cent sticker cannot be sold on its own.
// Integer-only money helpers. Keep every calculation in minor units.
const ZERO_DECIMAL = new Set(['jpy', 'krw', 'vnd', 'clp', 'xof', 'xaf']);
export function toMinorUnits(major: string, currency: string): number {
// Parse from a decimal string, never from a float, to avoid 0.1 + 0.2 bugs.
const [whole, frac = ''] = major.split('.');
if (ZERO_DECIMAL.has(currency.toLowerCase())) return Number(whole);
return Number(whole) * 100 + Number(frac.padEnd(2, '0').slice(0, 2));
}
export function formatMinor(amount: number, currency: string, locale = 'en-US'): string {
const divisor = ZERO_DECIMAL.has(currency.toLowerCase()) ? 1 : 100;
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / divisor);
}
// toMinorUnits('19.99', 'usd') === 1999
// formatMinor(1999, 'usd') === '$19.99'Stripe's API is form-encoded over HTTPS with HTTP Basic auth, where the secret key is the username. That makes curl the fastest way to build intuition.
# 1. Export a TEST key from Dashboard > Developers > API keys
export STRIPE_SECRET_KEY=sk_test_...
# 2. Create a customer (nested params use bracket syntax)
curl https://api.stripe.com/v1/customers \
-u "$STRIPE_SECRET_KEY:" \
-d email="ada@example.com" \
-d "metadata[internal_user_id]"=42
# 3. Create a $25.00 PaymentIntent and confirm it with a test card token
curl https://api.stripe.com/v1/payment_intents \
-u "$STRIPE_SECRET_KEY:" \
-d amount=2500 \
-d currency=usd \
-d payment_method=pm_card_visa \
-d confirm=true \
-d "automatic_payment_methods[enabled]"=true \
-d "automatic_payment_methods[allow_redirects]"=never
# 4. List the five most recent events your account produced
curl -G https://api.stripe.com/v1/events -u "$STRIPE_SECRET_KEY:" -d limit=5The official Node.js SDK wraps the same calls with types, automatic retries and pagination helpers.
// first-call.ts: run with `npx tsx --env-file=.env first-call.ts`
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
maxNetworkRetries: 2, // retries idempotent-safe failures with backoff
timeout: 20_000,
});
async function main() {
// 1. Create a customer
const customer = await stripe.customers.create({
email: 'ada@example.com',
metadata: { internal_user_id: '42' },
});
// 2. Charge a test card server-side (useful for scripts, not for real checkouts)
const intent = await stripe.paymentIntents.create({
amount: 2500,
currency: 'usd',
customer: customer.id,
payment_method: 'pm_card_visa',
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: 'never' },
});
console.log(intent.id, intent.status); // pi_..., succeeded
// 3. Auto-paginate: iterates every page lazily
for await (const event of stripe.events.list({ limit: 100 })) {
console.log(event.type);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});Every response carries a Request-Id header (req_...). Log it on every failure; it is the first thing Stripe support asks for and it links directly to the request in the Dashboard logs.
| Error Class (Node SDK) | type |
Typical Cause | Correct Reaction |
|---|---|---|---|
StripeCardError |
card_error |
Decline, insufficient funds, incorrect CVC | Show a friendly message; let the customer try another method. |
StripeInvalidRequestError |
invalid_request_error |
Bad params, missing object | Bug in your code. Alert, do not retry. |
StripeIdempotencyError |
idempotency_error |
Same key reused with different params | Bug. Keys must map 1:1 to an operation. |
StripeRateLimitError |
(HTTP 429) | Too many requests per second | Back off with jitter; the SDK retries automatically. |
StripeAPIError / StripeConnectionError |
api_error |
Stripe-side or network failure | Retry with the same idempotency key. |
StripeAuthenticationError |
(HTTP 401) | Wrong or revoked key | Page someone. |
import Stripe from 'stripe';
export async function chargeSafely(stripe: Stripe, params: Stripe.PaymentIntentCreateParams, key: string) {
try {
return await stripe.paymentIntents.create(params, { idempotencyKey: key });
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
// decline_code is more specific than code (e.g. "insufficient_funds")
return { declined: true, code: err.code, declineCode: err.decline_code };
}
if (err instanceof Stripe.errors.StripeError) {
console.error({ type: err.type, requestId: err.requestId, message: err.message });
}
throw err;
}
}API versioning. Stripe versions its API by date-stamped release names, and each account has a default version. Breaking changes only reach you when you upgrade. The typed SDKs pin the version they were generated against, so upgrading the stripe npm package is your API upgrade: read the changelog, run your webhook tests, then roll forward. Webhook endpoints have their own version setting; keep it aligned with the SDK so that event payloads match your types.
| Surface | You Build | Stripe Handles | Best For |
|---|---|---|---|
| Payment Links | Nothing (Dashboard or API) | Page, payment, receipts | Fixed-price items, invoices by email, no-code sellers. |
| Checkout (hosted) | A server endpoint that creates a session | Page, wallets, SCA, tax, localisation, address collection | Most web businesses. Fastest path to production. |
| Checkout (embedded) | Server endpoint + a mount point on your page | Same as hosted, rendered inside your site | Keeping the customer on your domain. |
| Payment Element + PaymentIntents | Server endpoint + your own checkout UI | Payment form, SCA, payment method logic | Fully custom flows, multi-step carts, mobile apps. |
The rule of thumb at staff level: start with Checkout and move down the table only when a requirement forces you to. Every step down transfers more edge cases (wallet eligibility, redirect-based methods, error copy, accessibility) from Stripe to your team.
The browser never chooses the amount. It sends an identifier; the server looks up the price, creates the session and returns a URL.
sequenceDiagram
participant B as Browser
participant A as Express API
participant S as Stripe
B->>A: POST /checkout-sessions { sku, quantity }
A->>A: Validate + price from server catalog
A->>A: Create order (status=pending)
A->>S: checkout.sessions.create (Idempotency-Key: checkout:ord_x)
S-->>A: { id: cs_..., url }
A-->>B: 201 { url }
B->>S: Redirect to hosted page
S-->>B: Redirect to success_url?session_id=cs_...
S-)A: checkout.session.completed (signed webhook)
A->>A: Mark order paid, fulfil
This is the heart of src/app.ts in the companion project:
app.post('/checkout-sessions', async (req: Request, res: Response) => {
let priced;
try {
priced = priceLineItem(req.body?.sku, req.body?.quantity); // server-side price lookup
} catch (err) {
if (err instanceof CatalogError) {
res.status(400).json({ error: err.message });
return;
}
throw err;
}
const order = store.create({
sku: priced.sku,
quantity: priced.quantity,
amount: priced.total,
currency: priced.item.currency,
});
try {
const session = await checkout.createCheckoutSession(
{
mode: 'payment',
line_items: [{
quantity: priced.quantity,
price_data: {
currency: priced.item.currency,
unit_amount: priced.item.unitAmount,
product_data: { name: priced.item.name },
},
}],
client_reference_id: order.id, // ties the session back to OUR order
metadata: { orderId: order.id, sku: priced.sku },
success_url: `${publicBaseUrl}/orders/${order.id}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${publicBaseUrl}/cart`,
},
`checkout:${order.id}`, // idempotency key: one session per order, ever
);
store.update(order.id, { checkoutSessionId: session.id });
res.status(201).json({ orderId: order.id, sessionId: session.id, url: session.url });
} catch (err) {
res.status(502).json({ error: 'Payment provider unavailable', orderId: order.id });
}
});Key parameters worth knowing:
mode:payment(one-off),subscription(recurring) orsetup(save a method, charge later).client_reference_id: your own identifier, echoed back on every event for the session. This is how you join Stripe data to your database without a lookup table.{CHECKOUT_SESSION_ID}: a literal template token. Stripe substitutes the real session ID into the success URL.customer/customer_email: attach a known customer so saved methods and receipts work.expires_at: sessions expire after 24 hours by default; shorten it for limited inventory and listen forcheckout.session.expiredto release stock.payment_intent_data: pass-through options for the underlying PaymentIntent (capture_method,metadata,setup_future_usage, Connect fields).
There are two ways to price a line item:
price: 'price_123': reference aPricecreated in the Dashboard or API. Best when finance owns the catalog and you want reporting by product.price_data: {...}: an inline, ad-hoc price. Best for dynamic carts, but the amount still comes from your database, never the request.
// src/catalog.ts: the browser sends a SKU and a quantity, never an amount
export const CATALOG: Readonly<Record<string, CatalogItem>> = Object.freeze({
'guide-ebook': { name: 'Stripe Field Guide (eBook)', unitAmount: 1900, currency: 'usd' },
'workshop-seat': { name: 'Payments Workshop Seat', unitAmount: 14900, currency: 'usd' },
'sticker-pack': { name: 'Sticker Pack', unitAmount: 500, currency: 'usd' },
});
export function priceLineItem(sku: unknown, quantity: unknown) {
// Object.hasOwn blocks prototype keys such as "__proto__" or "constructor"
if (typeof sku !== 'string' || !Object.hasOwn(CATALOG, sku)) {
throw new CatalogError('Unknown SKU');
}
if (typeof quantity !== 'number' || !Number.isInteger(quantity) || quantity < 1 || quantity > MAX_QUANTITY) {
throw new CatalogError(`Quantity must be an integer between 1 and ${MAX_QUANTITY}`);
}
const item = CATALOG[sku] as CatalogItem;
return { sku, quantity, item, total: item.unitAmount * quantity };
}Prices are immutable once used: you cannot edit an amount, only create a new price and archive the old one. Use lookup_key on prices so code can refer to pro_monthly instead of hard-coding price_... IDs that differ between test and live mode.
// Resolve prices by stable lookup keys rather than environment-specific IDs
const { data } = await stripe.prices.list({ lookup_keys: ['pro_monthly', 'pro_yearly'], active: true });
const byKey = Object.fromEntries(data.map((p) => [p.lookup_key, p.id]));A customer can close the tab before the redirect, lose connectivity, or simply type the success URL by hand. Conversely, a bank debit can reach the success page and fail three days later. The success page must therefore read state, not write it:
WRONG RIGHT
----- -----
GET /success?session_id=cs_... GET /orders/:id
-> mark order paid -> return order.status from the DB
-> send ebook (only the webhook handler writes "paid"
and triggers fulfillment)
If you want the success page to show an accurate answer before the webhook lands, retrieve the session server-side (stripe.checkout.sessions.retrieve(id)) and display payment_status, but still let the webhook be the single writer. Some teams also call the same idempotent fulfillment function from both paths; that is acceptable only if the function is genuinely idempotent (see Stage 4).
Networks fail between "Stripe created the object" and "your server received the response". Without protection, the retry creates a second PaymentIntent or a second refund. Idempotency keys make a POST safe to repeat.
flowchart TD
A[POST with Idempotency-Key K] --> B{Key K seen before?}
B -- No --> C[Execute request, store response for K]
B -- Yes, same params --> D[Replay stored response, no new side effect]
B -- Yes, different params --> E[400 idempotency_error]
C --> F[Return response]
D --> F
-
Derive keys from your domain, not from random UUIDs generated per attempt:
checkout:${orderId},refund:${refundRequestId}. A per-attempt UUID protects nothing. - Reuse the same key on every retry of the same logical operation, including retries after a process restart.
- Keys are remembered for at least 24 hours. For longer-lived protection, also enforce uniqueness in your own database.
-
The SDK's
maxNetworkRetriesautomatically adds an idempotency key to retried POSTs, but only within a single call. Your own key covers retries across calls and processes.
Embedded Checkout keeps the same server-side session but renders it inside your page. Create the session with ui_mode: 'embedded_page' (the default is hosted_page) and return its client_secret instead of a URL; the client mounts it with Stripe.js. Older API versions called these modes embedded and hosted; the current SDK uses the _page names.
// Server: embedded mode returns a client_secret instead of a redirect URL
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded_page',
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
return_url: `${baseUrl}/orders/${orderId}?session_id={CHECKOUT_SESSION_ID}`,
client_reference_id: orderId,
});
res.json({ clientSecret: session.client_secret });// Browser: mount Checkout inside a <div id="checkout"></div>
// (/checkout-sessions/embedded is your own route wrapping the server code above)
import { loadStripe } from '@stripe/stripe-js';
const stripe = await loadStripe('pk_test_...');
const checkout = await stripe!.createEmbeddedCheckoutPage({
fetchClientSecret: async () => {
const res = await fetch('/checkout-sessions/embedded', { method: 'POST' });
const { clientSecret } = (await res.json()) as { clientSecret: string };
return clientSecret;
},
});
checkout.mount('#checkout');Other high-leverage Checkout options: automatic_tax: { enabled: true } with Stripe Tax, allow_promotion_codes: true, shipping_address_collection, phone_number_collection, consent_collection for terms acceptance, locale, and custom branding from Dashboard settings. Payment Links are the zero-code sibling: create one in the Dashboard or with stripe.paymentLinks.create({ line_items }), and handle fulfillment from the same checkout.session.completed webhook.
When you need your own checkout UI, you work directly with the PaymentIntent: a server-created object representing "collect this amount from this customer", which the client confirms with payment details.
stateDiagram-v2
[*] --> requires_payment_method
requires_payment_method --> requires_confirmation: payment method attached
requires_confirmation --> requires_action: 3DS / redirect needed
requires_confirmation --> processing
requires_action --> processing: customer completes action
requires_action --> requires_payment_method: authentication failed
processing --> succeeded
processing --> requires_payment_method: payment failed
processing --> requires_capture: capture_method=manual
requires_capture --> succeeded: capture
requires_capture --> canceled: cancel or hold expires
requires_payment_method --> canceled
succeeded --> [*]
canceled --> [*]
| Status | Meaning | Your Move |
|---|---|---|
requires_payment_method |
No method yet, or the last attempt failed | Show the form again. |
requires_confirmation |
Method attached, not yet confirmed | Confirm (usually the client does this). |
requires_action |
Customer must authenticate (3DS, bank redirect) | Stripe.js handles it; your UI waits. |
processing |
Submitted; outcome pending (common for bank debits) | Wait for a webhook. Do not fulfil. |
requires_capture |
Authorised, funds held | Capture within the hold window, or cancel. |
succeeded |
Funds captured | Fulfil (from the webhook). |
canceled |
Abandoned or voided | Release inventory. |
A failed attempt sends the intent back to requires_payment_method rather than to a terminal failed state. One PaymentIntent therefore represents the whole purchase, however many cards the customer tries, which is what makes it safe to reuse per order.
// routes/payment-intents.ts
import type { Request, Response } from 'express';
import type Stripe from 'stripe';
export function createPaymentIntentRoute(stripe: Stripe, orders: OrdersRepo) {
return async (req: Request, res: Response) => {
const order = await orders.getForUser(req.body.orderId, req.user.id);
if (!order) return res.status(404).end();
// 1. Reuse the intent if this order already has one (one intent per order)
if (order.paymentIntentId) {
const existing = await stripe.paymentIntents.retrieve(order.paymentIntentId);
return res.json({ clientSecret: existing.client_secret });
}
// 2. Create with an amount computed on the server and a domain-derived idempotency key
const intent = await stripe.paymentIntents.create(
{
amount: order.totalMinor,
currency: order.currency,
customer: order.stripeCustomerId,
automatic_payment_methods: { enabled: true }, // let Dashboard settings decide methods
metadata: { orderId: order.id },
receipt_email: order.email,
},
{ idempotencyKey: `pi:${order.id}` },
);
await orders.attachPaymentIntent(order.id, intent.id);
// 3. Only the client_secret goes to the browser, never the secret key
res.json({ clientSecret: intent.client_secret });
};
}If the cart changes before payment, update the existing intent with stripe.paymentIntents.update(id, { amount }) instead of creating a new one.
The Payment Element is a single prebuilt UI component that renders every payment method enabled for the account (cards, wallets, bank debits, local methods) and handles SCA flows.
// checkout.ts (browser)
import { loadStripe } from '@stripe/stripe-js';
const stripe = (await loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY))!;
// 1. Ask the server for a client_secret bound to this order
const { clientSecret } = await fetch('/payment-intents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId }),
}).then((r) => r.json());
// 2. Mount the Payment Element
const elements = stripe.elements({ clientSecret, appearance: { theme: 'stripe' } });
elements.create('payment', { layout: 'tabs' }).mount('#payment-element');
// 3. Confirm on submit; Stripe.js runs 3DS or redirects as needed
document.querySelector('#pay-form')!.addEventListener('submit', async (e) => {
e.preventDefault();
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${location.origin}/orders/${orderId}` },
});
// Only immediate errors land here (validation, declines). Success redirects away.
if (error) showError(error.message ?? 'Payment failed');
});After a redirect, Stripe appends payment_intent and payment_intent_client_secret to the return_url. Use stripe.retrievePaymentIntent(clientSecret) to render the status, and still fulfil only from the webhook.
European (PSD2) and several other regulations require Strong Customer Authentication for many online card payments: two of knowledge, possession and inherence. On cards this is implemented as 3D Secure, where the issuer may challenge the customer with an app prompt or a one-time code.
- On-session payments: Stripe.js handles the challenge automatically when the intent moves to
requires_action. Your code does nothing extra beyond usingconfirmPayment. - Exemptions: low-value, low-risk and merchant-initiated transactions may be exempt; Stripe requests exemptions where it can, but the issuer decides.
- Liability shift: a successfully authenticated 3DS payment usually shifts fraud-dispute liability to the issuer.
- Off-session payments (renewals, usage charges) cannot show a challenge. If the issuer insists, the charge fails with
authentication_requiredand you must bring the customer back on-session (Stage 3.6).
Test with 4000 0025 0000 3155, which requires authentication on every on-session payment in test mode.
Marketplaces, hotels and made-to-order shops often need to hold funds and settle the final amount later.
// 1. Authorise only: funds are held, nothing is captured
const intent = await stripe.paymentIntents.create(
{
amount: 12_000, // maximum you might charge
currency: 'usd',
customer: customerId,
payment_method: paymentMethodId,
capture_method: 'manual',
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: 'never' },
},
{ idempotencyKey: `auth:${bookingId}` },
);
// intent.status === 'requires_capture'
// 2a. Capture the final amount (may be less than authorised, never more)
await stripe.paymentIntents.capture(intent.id, { amount_to_capture: 9_850 }, { idempotencyKey: `capture:${bookingId}` });
// 2b. Or release the hold entirely
await stripe.paymentIntents.cancel(intent.id, { cancellation_reason: 'abandoned' });Card authorisations expire. For most online card payments the window is about seven days; after that Stripe cancels the intent and the hold disappears. Build a scheduled job that captures or cancels well before expiry, and listen for payment_intent.canceled as a safety net.
A SetupIntent collects and authenticates a payment method now so you can charge it later without the customer present: free trials, usage billing, post-paid rides.
// 1. On-session: create a SetupIntent and confirm it with the Payment Element (stripe.confirmSetup)
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
usage: 'off_session', // tells issuers you will charge without the customer present
automatic_payment_methods: { enabled: true },
});
// send setupIntent.client_secret to the browser
// 2. Later, off-session: charge the saved method
export async function chargeSaved(stripe: Stripe, customerId: string, pmId: string, amount: number, key: string) {
try {
return await stripe.paymentIntents.create(
{ amount, currency: 'usd', customer: customerId, payment_method: pmId, off_session: true, confirm: true },
{ idempotencyKey: key },
);
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError && err.code === 'authentication_required') {
// 3. The issuer wants 3DS: email the customer a link back to an on-session page
// that confirms err.payment_intent with the Payment Element.
return { needsCustomer: true, paymentIntentId: err.payment_intent?.id };
}
throw err;
}
}During a one-off purchase you can achieve the same by passing setup_future_usage: 'off_session' on the PaymentIntent (or payment_intent_data.setup_future_usage on a Checkout Session), which saves the method while charging it.
A webhook is an HTTPS POST that Stripe sends to your endpoint whenever an Event is created. Payments are asynchronous by nature (bank debits settle in days, disputes arrive weeks later, subscriptions renew at 3 a.m.), so the only reliable way to learn the outcome is to let Stripe tell you.
flowchart LR
S[Stripe] -- signed POST --> E["/webhooks/stripe"]
E --> V{Signature valid?}
V -- no --> R400[400, log, drop]
V -- yes --> D{Event id already processed?}
D -- yes --> R200a[200 duplicate]
D -- no --> H[Handle: state transition]
H -- ok --> R200[200]
H -- throws --> R500[500, Stripe retries]
Events your fulfillment should almost always subscribe to:
| Flow | Events |
|---|---|
| Checkout (payment) | checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired |
| Custom PaymentIntents | payment_intent.succeeded, payment_intent.payment_failed, payment_intent.processing, payment_intent.canceled, payment_intent.amount_capturable_updated |
| Subscriptions | customer.subscription.created/updated/deleted, invoice.paid, invoice.payment_failed, invoice.payment_action_required, customer.subscription.trial_will_end |
| Money backwards | charge.refunded, refund.updated, charge.dispute.created, charge.dispute.closed |
| Connect | account.updated, payout.failed, account.application.deauthorized |
Subscribe each endpoint to only the events it handles. Fewer events means less load, smaller blast radius and clearer logs.
Anyone on the internet can POST JSON to your endpoint, so every delivery is signed with the endpoint's whsec_ secret.
Stripe-Signature: t=1793481600,v1=6ffbb59b2300aae63f2723...,v1=9c1f2e...
| |
| +-- HMAC-SHA256(secret, "<t>.<raw body>") as hex
| (several v1 values appear during secret rotation)
+-- Unix timestamp when Stripe signed the payload
Verification, step by step:
- Split the header into
tand everyv1value (ignore other schemes). - Build the signed payload: the timestamp, a literal
., then the exact raw request bytes. - Compute HMAC-SHA256 with the endpoint secret and compare to each
v1with a constant-time comparison. - Reject if the timestamp is older than your tolerance (the SDK default is 300 seconds) to block replays.
The SDK does all of this in one call. Writing it by hand is a good exercise for understanding, and a bad idea for production:
// Educational only: what stripe.webhooks.constructEvent does under the hood
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyStripeSignature(raw: Buffer, header: string, secret: string, toleranceSec = 300): boolean {
const parts = header.split(',').map((kv) => kv.split('=') as [string, string]);
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!Number.isFinite(t) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay window
const expected = createHmac('sha256', secret).update(`${t}.`).update(raw).digest();
return signatures.some((sig) => {
const given = Buffer.from(sig, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}The companion project wraps the SDK call behind a small interface so tests can use real signatures without the network:
// src/payments.ts
export function stripeWebhookVerifier(stripe: Stripe, endpointSecret: string, toleranceSeconds = 300): WebhookVerifier {
return {
verify(rawBody, signatureHeader) {
return stripe.webhooks.constructEvent(rawBody, signatureHeader, endpointSecret, toleranceSeconds);
},
};
}The most common webhook bug in Node.js: a global express.json() parses the body before your handler sees it. Re-serialising that object changes whitespace and key order, the HMAC no longer matches, and every event fails with "No signatures found matching the expected signature for payload".
BROKEN FIXED
------ -----
app.use(express.json()); app.post('/webhooks/stripe',
app.post('/webhooks/stripe', handler); express.raw({ type: 'application/json' }),
// req.body is an object, bytes are gone handler); // req.body is a Buffer
app.use(express.json()); // everything else
// src/app.ts (excerpt): webhook route registered BEFORE the JSON parser
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json', limit: '1mb' }),
(req: Request, res: Response) => {
const signature = req.header('stripe-signature');
if (!signature || !Buffer.isBuffer(req.body)) {
res.status(400).json({ error: 'Missing Stripe-Signature header or body' });
return;
}
let event;
try {
event = webhooks.verify(req.body, signature);
} catch (err) {
res.status(400).json({ error: 'Invalid signature' });
return;
}
if (!store.claimEvent(event.id)) {
res.status(200).json({ received: true, duplicate: true });
return;
}
try {
const result = handleStripeEvent(event, store);
res.status(200).json({ received: true, result });
} catch (err) {
store.releaseEvent(event.id); // let Stripe's retry try again
res.status(500).json({ error: 'Handler failed' });
}
},
);
app.use(express.json({ limit: '16kb' }));The same trap exists elsewhere: in Next.js route handlers read await req.text(); in Fastify register a raw-body content-type parser for the route; behind API gateways make sure nothing base64-decodes, pretty-prints or re-encodes the payload.
Stripe guarantees at-least-once delivery and does not guarantee ordering. Design for both:
- Deduplicate on
event.id. In production, insert the id into aprocessed_eventstable with a unique constraint inside the same transaction as the state change. A unique-violation means "already done". - Make transitions monotonic. A late
checkout.session.expiredmust never overwritepaid; apayment_intent.processingarriving aftersucceededmust be ignored. - Prefer re-fetching for complex objects. When order matters (subscriptions), treat the event as a nudge and
retrievethe current object from the API before deciding. - Return 2xx for events you do not care about, or Stripe will keep retrying them.
// src/fulfillment.ts (excerpt): monotonic, replay-safe transitions
case 'checkout.session.completed': {
const session = event.data.object;
const orderId = orderIdFrom(session); // client_reference_id, then metadata.orderId
const order = orderId ? store.get(orderId) : undefined;
if (!order) return 'ignored';
const piId = paymentIntentId(session.payment_intent);
// A late "completed" must not undo an async_payment_* outcome that already landed
if (order.status !== 'pending' && order.status !== 'awaiting_payment') {
if (piId && !order.paymentIntentId) store.update(order.id, { paymentIntentId: piId });
return 'ignored';
}
// Cards report 'paid' immediately; bank debits report 'unpaid' and settle later
const status = session.payment_status === 'unpaid' ? 'awaiting_payment' : 'paid';
store.update(order.id, { status, ...(piId ? { paymentIntentId: piId } : {}) });
return 'handled';
}
case 'checkout.session.expired': {
const orderId = orderIdFrom(event.data.object);
const order = orderId ? store.get(orderId) : undefined;
if (!order || order.status !== 'pending') return 'ignored'; // never downgrade a paid order
store.update(order.id, { status: 'expired' });
return 'handled';
}A production-grade version of the dedupe step in SQL:
-- One row per Stripe event; the primary key is the idempotency guard
CREATE TABLE processed_stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Inside the handler transaction:
-- INSERT INTO processed_stripe_events (event_id, event_type) VALUES ($1, $2)
-- ON CONFLICT (event_id) DO NOTHING RETURNING event_id;
-- No row returned => duplicate, commit and return 200 without side effects.Stripe expects a fast 2xx. If your endpoint times out or returns a non-2xx status, Stripe retries with exponential backoff for up to three days in live mode (fewer, shorter retries in test mode) and eventually emails you about a failing endpoint.
flowchart LR
S[Stripe] --> W[Webhook receiver]
W -->|verify + persist raw event| Q[(Queue / outbox table)]
W -->|200 in < 1s| S
Q --> K1[Worker: fulfil order]
Q --> K2[Worker: send email]
Q --> K3[Worker: sync CRM]
K1 -->|failure| DLQ[(Dead-letter queue)]
- Verify, persist, acknowledge. The receiver does the minimum synchronously, then hands work to a queue (SQS, Pub/Sub, BullMQ, a Postgres outbox).
- Workers are idempotent too, because queues also deliver at least once.
- Recovery after an outage: list missed events with
stripe.events.list({ created: { gte: outageStart }, type: 'checkout.session.completed' })and feed them through the same handler. Event objects are retained for roughly 30 days. - Secret rotation: rolling an endpoint secret in the Dashboard can keep the old secret valid for a grace period, during which deliveries carry two
v1signatures. Deploy the new secret before the grace period ends.
The Stripe CLI forwards test-mode events to localhost and signs them with a temporary whsec_ secret.
# 1. Authenticate the CLI against your test account (opens a browser)
stripe login
# 2. Forward events to the local server; copy the printed whsec_ into .env
stripe listen --forward-to localhost:4242/webhooks/stripe \
--events checkout.session.completed,checkout.session.expired,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,charge.refunded
# 3. In another terminal, run the service
npm run dev
# 4. Fire synthetic events (these create real test-mode objects)
stripe trigger checkout.session.completed
stripe trigger charge.refunded
# 5. Replay a specific event and tail API request logs
stripe events resend evt_1Q...
stripe logs tailFor automated tests you do not need the CLI at all. stripe.webhooks.generateTestHeaderString({ payload, secret }) produces a valid header for any fixture payload, which is exactly how tests/app.test.ts exercises valid, tampered, wrong-secret and replayed deliveries.
// tests/app.test.ts (excerpt): real signatures over fixture events, fully offline
const payload = JSON.stringify(eventFixture('checkout.session.completed', checkoutSessionObject(orderId)));
const header = stripe.webhooks.generateTestHeaderString({ payload, secret: WEBHOOK_SECRET });
const res = await request(app)
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.set('Stripe-Signature', header)
.send(payload);
assert.equal(res.status, 200);
assert.equal(store.get(orderId)!.status, 'paid');A subscription is a customer plus one or more recurring prices. Each billing period Stripe generates an invoice, finalises it, and attempts payment through a PaymentIntent.
// 1. One product, two recurring prices addressed by lookup keys
const product = await stripe.products.create({ name: 'Learning Hub Pro' });
await stripe.prices.create({
product: product.id,
currency: 'usd',
unit_amount: 1200,
recurring: { interval: 'month' },
lookup_key: 'pro_monthly',
});
await stripe.prices.create({
product: product.id,
currency: 'usd',
unit_amount: 12000,
recurring: { interval: 'year' },
lookup_key: 'pro_yearly',
});
// 2. Start a subscription through Checkout, with a 14-day trial
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId, // reuse the customer so one person never has two subscriptions
line_items: [{ price: proMonthlyPriceId, quantity: 1 }],
subscription_data: { trial_period_days: 14, metadata: { userId } },
success_url: `${baseUrl}/account?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/pricing`,
});Pricing models that map to Price configuration: flat rate, per-seat (quantity), tiered (billing_scheme: 'tiered' with volume or graduated tiers) and usage-based billing, where you report usage to a billing meter and Stripe aggregates it into the invoice.
stateDiagram-v2
[*] --> incomplete: first invoice unpaid
[*] --> trialing: trial configured
[*] --> active: first payment succeeds
incomplete --> active: payment completed (~23h window)
incomplete --> incomplete_expired: window passes
trialing --> active: trial ends, invoice paid
trialing --> paused: trial ends with no payment method (if configured)
active --> past_due: renewal payment fails
past_due --> active: retry succeeds
past_due --> canceled: retries exhausted (policy)
past_due --> unpaid: retries exhausted (policy)
active --> canceled: canceled immediately or at period end
paused --> active: resumed
canceled --> [*]
incomplete_expired --> [*]
Map these statuses to entitlements in your own database rather than checking Stripe on every request:
| Status | Grant Access? | Notes |
|---|---|---|
trialing, active |
Yes | Normal paying (or trial) state. |
past_due |
Usually yes, with a banner | Dunning in progress; give a grace period. |
incomplete |
No | Initial payment needs action (e.g. 3DS). |
unpaid, canceled, incomplete_expired |
No | Terminal or suspended. |
paused |
No | Resume with stripe.subscriptions.resume. |
// Webhook handler: keep entitlements in sync, re-fetching to defeat out-of-order delivery
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const fresh = await stripe.subscriptions.retrieve(event.data.object.id);
const active = ['trialing', 'active', 'past_due'].includes(fresh.status);
await entitlements.upsert({
userId: fresh.metadata.userId,
subscriptionId: fresh.id,
status: fresh.status,
plan: fresh.items.data[0]?.price.lookup_key ?? 'unknown',
access: active,
});
break;
}invoice.paidis the canonical "extend access for another period" signal. Prefer it toinvoice.payment_succeededfor entitlement logic because it also fires when an invoice is marked paid out of band.invoice.payment_failedstarts dunning. Stripe's Smart Retries re-attempt the payment on days most likely to succeed; configure the schedule and the end state (cancel, mark unpaid, leave past due) in Billing settings.invoice.payment_action_requiredmeans the renewal needs 3DS. Email the customer the hosted invoice URL (invoice.hosted_invoice_url).customer.subscription.trial_will_endfires a few days before a trial converts. Remind the customer, especially if no payment method is on file.
The Customer Portal is a Stripe-hosted page where customers update cards, download invoices, switch plans and cancel, configured from the Dashboard.
// One endpoint gives customers full self-service billing
app.post('/billing/portal', requireUser, async (req, res) => {
const portal = await stripe.billingPortal.sessions.create({
customer: req.user.stripeCustomerId,
return_url: `${baseUrl}/account`,
});
res.redirect(303, portal.url);
});When a customer switches plans mid-cycle, Stripe can prorate: credit unused time on the old price and charge for the remaining time on the new one.
Upgrading from $12 to proration_behavior: 'always_invoice').
// 1. Preview the next invoice before committing, so the UI can show the exact amount
const preview = await stripe.invoices.createPreview({
customer: customerId,
subscription: subscriptionId,
subscription_details: {
items: [{ id: subscriptionItemId, price: newPriceId }],
proration_behavior: 'create_prorations',
},
});
console.log('Due on next invoice:', preview.amount_due);
// 2. Apply the change
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscriptionItemId, price: newPriceId }],
proration_behavior: 'create_prorations', // or 'always_invoice' / 'none'
});
// 3. Downgrades often wait for the period end; cancellations too
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true });For changes scheduled in the future ("switch to annual at renewal"), use Subscription Schedules, which hold an ordered list of phases.
// Refund service: idempotent per refund request, partial by default
export async function refundOrder(stripe: Stripe, order: PaidOrder, amount: number, requestId: string) {
if (amount <= 0 || amount > order.amount - (order.amountRefunded ?? 0)) {
throw new Error('Refund exceeds refundable balance');
}
const refund = await stripe.refunds.create(
{
payment_intent: order.paymentIntentId,
amount, // omit for a full refund
reason: 'requested_by_customer', // or 'duplicate' / 'fraudulent'
metadata: { orderId: order.id, refundRequestId: requestId },
},
{ idempotencyKey: `refund:${requestId}` },
);
return refund.status; // 'pending' | 'succeeded' | 'failed' | 'requires_action' | 'canceled'
}- Refunds are asynchronous. Card refunds usually succeed quickly but take days to appear on the statement; some methods stay
pendingor later move tofailed. Listen tocharge.refundedandrefund.updatedrather than trusting the create response. - Track cumulative amounts.
charge.amount_refundedis the running total;charge.refundedbecomestrueonly when fully refunded. The companion service keeps the orderpaidafter a partial refund and flips it torefundedon a full refund. - Fees. Processing fees are generally not returned on refunds. Check current pricing for your account rather than assuming.
- Refund versus cancel. If an intent is still
requires_capture, cancel it instead: releasing a hold costs nothing and never appears as a refund.
A dispute happens when a cardholder asks their issuer to reverse a payment. Stripe immediately debits the disputed amount plus a dispute fee from your balance and opens a case with an evidence deadline.
sequenceDiagram
participant CH as Cardholder
participant IS as Issuer
participant S as Stripe
participant M as Merchant
CH->>IS: "I don't recognise this charge"
IS->>S: Chargeback (reason code)
S-)M: charge.dispute.created (funds withdrawn)
M->>S: disputes.update(evidence, submit: true)
S->>IS: Evidence package
IS-->>S: Decision (weeks later)
S-)M: charge.dispute.closed (won: funds reinstated / lost)
// Submit evidence collected automatically from your systems
await stripe.disputes.update(disputeId, {
evidence: {
customer_email_address: order.email,
customer_purchase_ip: order.ip,
product_description: 'Digital eBook, delivered instantly by download link',
access_activity_log: await downloads.logFor(order.id), // proves usage
receipt: receiptFileId, // uploaded earlier via stripe.files.create
},
submit: true, // without this the evidence is saved as a draft
});Prevention beats evidence: a recognisable statement descriptor, clear receipts, easy refunds, Radar rules and 3DS on risky payments all lower dispute rates. Card networks run monitoring programs that penalise merchants whose dispute ratio climbs too high, so watch that metric like an SLO.
Connect lets a platform move money on behalf of other businesses: marketplaces, SaaS platforms that take payments for their customers, gig apps that pay workers. The platform owns the integration; each seller is a connected account (acct_...).
graph TD
subgraph Platform["Platform account (you)"]
API[Your API + Stripe secret key]
end
subgraph Connected["Connected accounts"]
A1[acct_bakery]
A2[acct_florist]
end
C[Customer] -- pays --> API
API -- destination charge + application fee --> A1
API -- direct charge on behalf of --> A2
A1 -- payout --> B1[Bakery bank]
A2 -- payout --> B2[Florist bank]
| Charge Type | Who the Charge Belongs To | Fees, Refunds & Disputes | Typical Use |
|---|---|---|---|
| Direct charge | The connected account (stripeAccount request option) |
Connected account bears them; platform takes application_fee_amount |
SaaS platforms where sellers are the merchant of record. |
| Destination charge | The platform, auto-transferred to one account (transfer_data.destination) |
Platform bears them by default | Marketplaces with one seller per order. |
| Separate charges & transfers | The platform, transferred later with transfers.create |
Platform bears them | Multi-seller carts, delayed payouts, splitting one payment. |
// Destination charge through Checkout: platform keeps 10% as its fee
const total = 10_000; // $100.00
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price_data: { currency: 'usd', unit_amount: total, product_data: { name: 'Wedding bouquet' } }, quantity: 1 }],
payment_intent_data: {
application_fee_amount: Math.round(total * 0.1), // 1000 cents to the platform
transfer_data: { destination: 'acct_florist123' },
},
success_url: `${baseUrl}/orders/${orderId}`,
cancel_url: `${baseUrl}/cart`,
});
// Direct charge: the same API, executed AS the connected account
await stripe.paymentIntents.create(
{ amount: total, currency: 'usd', application_fee_amount: 1000, automatic_payment_methods: { enabled: true } },
{ stripeAccount: 'acct_bakery456' },
);
// Separate charges & transfers: split one payment across two sellers later
await stripe.transfers.create({ amount: 6000, currency: 'usd', destination: 'acct_bakery456', transfer_group: `order_${orderId}` });
await stripe.transfers.create({ amount: 3000, currency: 'usd', destination: 'acct_florist123', transfer_group: `order_${orderId}` });With a 10% fee on a $100 order:
Connected accounts must pass identity verification (KYC) before they can accept charges or receive payouts. Stripe now configures accounts by controller properties (who pays fees, who covers losses, which dashboard the account gets, who collects requirements) instead of only the older Standard / Express / Custom labels.
// 1. Create an account where the platform pays fees and covers losses, with an Express-style dashboard
const account = await stripe.accounts.create({
controller: {
fees: { payer: 'application' },
losses: { payments: 'application' },
stripe_dashboard: { type: 'express' },
},
capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
metadata: { sellerId },
});
// 2. Send the seller through Stripe-hosted onboarding
const link = await stripe.accountLinks.create({
account: account.id,
type: 'account_onboarding',
refresh_url: `${baseUrl}/sellers/onboarding/refresh`, // link expired or reused: mint a new one
return_url: `${baseUrl}/sellers/onboarding/done`, // NOT proof that onboarding finished
});
res.redirect(303, link.url);// 3. A Connect webhook endpoint (events from connected accounts carry event.account)
case 'account.updated': {
const acct = event.data.object;
await sellers.update(acct.metadata?.sellerId, {
chargesEnabled: acct.charges_enabled,
payoutsEnabled: acct.payouts_enabled,
requirementsDue: acct.requirements?.currently_due ?? [],
});
break;
}Gate checkout for a seller on charges_enabled, and keep watching requirements.currently_due: verification requirements can reappear months later when volumes grow or regulations change.
Test mode behaves like production without moving money. Use sandboxes to give each developer or CI run an isolated account, so test data stays clean and webhook endpoints do not collide.
| Card Number | Behaviour in Test Mode |
|---|---|
4242 4242 4242 4242 |
Succeeds. Any future expiry, any CVC, any postcode. |
4000 0025 0000 3155 |
Requires 3D Secure authentication on-session. |
4000 0000 0000 9995 |
Declined with insufficient_funds. |
4000 0000 0000 0002 |
Declined with a generic card_declined. |
4000 0000 0000 0077 |
Succeeds, and funds go straight to the available balance (useful for Connect transfer tests). |
4000 0000 0000 0259 |
Succeeds, then opens a dispute so you can test evidence flows. |
Server-side scripts can use test PaymentMethod tokens such as pm_card_visa, pm_card_chargeDeclined or pm_card_authenticationRequired instead of raw numbers. Raw card numbers should never be sent to your server, even in test mode, so keep that habit from day one.
A disciplined test pyramid for a Stripe integration:
/\ E2E (rare): real test-mode Checkout in a browser, stripe listen
/ \
/----\ Contract: stripe trigger / test clocks against a sandbox
/ \
/--------\ Integration: Express app + fake gateway + REAL webhook signatures
/ \ (tests/app.test.ts: 20 tests, zero network calls)
/------------\ Unit: pricing, money math, state transitions
Waiting a month to see a renewal fail is not a test strategy. Test clocks let you create customers attached to a simulated clock and advance time.
// 1. Freeze time at "now" and attach a new customer to the clock
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
name: 'renewal-fails-after-trial',
});
const customer = await stripe.customers.create({
email: 'clock@example.com',
test_clock: clock.id,
payment_method: 'pm_card_visa',
invoice_settings: { default_payment_method: 'pm_card_visa' },
});
// 2. Start a subscription with a 7-day trial
const sub = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: proMonthlyPriceId }],
trial_period_days: 7,
});
// 3. Jump 8 days ahead: the trial converts and the first invoice is attempted
await stripe.testHelpers.testClocks.advance(clock.id, {
frozen_time: clock.frozen_time + 8 * 24 * 60 * 60, // relative to the clock, not the wall clock
});
// Poll until clock.status === 'ready', then assert on webhooks and sub.statusSwap the default payment method to a failing test card before advancing to rehearse dunning, past_due banners and cancellation emails end to end.
- PCI scope. With Checkout, Payment Links or Elements, card data goes from the browser straight to Stripe, which keeps you in the lightest self-assessment category. Building your own card fields that touch your servers multiplies your compliance burden. Do not do it.
- Key hygiene. Secrets live in a secrets manager, never in git or Docker images. Use restricted keys per service, rotate on staff departures, and alert on unexpected key usage from the Dashboard logs.
- Webhook hardening. Verify signatures, enforce the timestamp tolerance, cap body size (
limit: '1mb'), and never act on an event's object without verification. Do not rely on IP allow-lists alone; signatures are the control. - Never trust the client. Amounts, currencies, discounts and Connect destinations are computed server-side. The companion tests assert that a client-sent
amountis ignored. - Fraud. Radar scores every payment; add rules (block if CVC check fails, review high-risk countries) and listen for
radar.early_fraud_warning.created, often the cheapest moment to refund a fraudulent charge before it turns into a dispute. - Card testing attacks. Bots use checkout endpoints to validate stolen cards. Rate-limit session creation per IP and account, add CAPTCHA on suspicious traffic, and watch for spikes in small declined payments.
The repository ships a small, fully tested Express + TypeScript service that implements this guide's core loop.
learn-stripe/
βββ src/
β βββ app.ts # Express routes: checkout, webhook (raw body), orders, health
β βββ catalog.ts # Server-side price list and input validation
β βββ config.ts # Env loading; refuses live keys unless explicitly allowed
β βββ fulfillment.ts # Event -> order state transitions (idempotent, monotonic)
β βββ payments.ts # Narrow ports over the Stripe SDK (gateway + verifier)
β βββ store.ts # In-memory orders + processed-event ledger
β βββ types.ts # Domain types
β βββ index.ts # Entry point wiring real Stripe adapters
βββ tests/app.test.ts # node:test + supertest, real HMAC signatures, no network
βββ Dockerfile
βββ package.json
βββ tsconfig.json
# 1. Install and run the offline test suite (fake keys, fixture events)
npm install
npm test
# 2. Configure TEST-mode credentials
cp .env.example .env # fill STRIPE_SECRET_KEY=sk_test_...
# 3. Start forwarding webhooks and paste the printed whsec_ into .env
stripe listen --forward-to localhost:4242/webhooks/stripe
# 4. Run the server and create a session
npm run dev
curl -s -X POST localhost:4242/checkout-sessions \
-H 'Content-Type: application/json' \
-d '{"sku":"guide-ebook","quantity":2}'
# -> { "orderId": "ord_...", "sessionId": "cs_test_...", "url": "https://checkout.stripe.com/..." }
# 5. Open the url, pay with 4242 4242 4242 4242, then check the order
curl -s localhost:4242/orders/ord_...# Production Dockerfile for the Learn Stripe companion service
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
COPY tests ./tests
RUN npm run typecheck && npm test && npm run build
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 4242
USER node
CMD ["node", "dist/index.js"]Log every Stripe interaction as structured JSON with the identifiers needed to join systems: orderId, paymentIntentId, eventId, requestId, idempotencyKey. Metrics that deserve dashboards and alerts:
| Metric | Why It Matters | Alert When |
|---|---|---|
| Webhook signature failures / min | Misconfigured secret or attack | Any sustained non-zero rate. |
| Webhook handler p99 latency | Stripe times out slow endpoints | Approaching a few seconds. |
| Unprocessed events age | Fulfillment backlog | Oldest unhandled event > 5 minutes. |
| Checkout sessions created vs completed | Conversion and outages | Completion rate drops sharply. |
Decline rate by decline_code |
Fraud attacks, issuer issues | Spike in card_declined on small amounts. |
| Dispute ratio | Network monitoring programs | Trending upward month on month. |
| Daily reconciliation diff | Money integrity | Any mismatch between DB and balance transactions. |
Go-live checklist:
- Live keys in the secrets manager; restricted keys for every service that can use one.
- Live webhook endpoint created, subscribed to the minimum event set, secret deployed, endpoint API version aligned with the SDK.
- Idempotency keys on every POST that moves money; dedupe table for events.
- Statement descriptor, support contact, receipt emails and refund policy configured.
- Radar rules reviewed; 3DS behaviour tested with authentication test cards.
- Reconciliation job comparing orders to balance transactions and payouts.
- Runbooks: webhook outage replay, key rotation, mass refund, dispute response.
| Concern | Tool / API | Command / Usage |
|---|---|---|
| Hosted payment page | Checkout Session | stripe.checkout.sessions.create({ mode: 'payment', line_items, success_url, cancel_url }) |
| Custom payment UI | PaymentIntent + Payment Element | paymentIntents.create({ amount, currency, automatic_payment_methods: { enabled: true } }) then stripe.confirmPayment() |
| Safe retries | Idempotency keys | { idempotencyKey: 'checkout:' + orderId } as the second SDK argument |
| Verify webhooks | constructEvent |
stripe.webhooks.constructEvent(rawBody, sigHeader, whsec, 300) |
| Test signatures offline | generateTestHeaderString |
stripe.webhooks.generateTestHeaderString({ payload, secret }) |
| Raw body in Express | express.raw |
Register express.raw({ type: 'application/json' }) on the webhook route before express.json() |
| Local webhooks | Stripe CLI | stripe listen --forward-to localhost:4242/webhooks/stripe |
| Synthetic events | Stripe CLI | stripe trigger checkout.session.completed |
| Replay an event | Stripe CLI | stripe events resend evt_... |
| Save a card for later | SetupIntent | setupIntents.create({ customer, usage: 'off_session' }) |
| Hold then charge | Manual capture | capture_method: 'manual' then paymentIntents.capture(id, { amount_to_capture }) |
| Subscriptions | Checkout subscription mode | mode: 'subscription', line_items: [{ price, quantity: 1 }] |
| Self-service billing | Customer Portal | billingPortal.sessions.create({ customer, return_url }) |
| Plan change preview | Invoice preview | invoices.createPreview({ customer, subscription, subscription_details }) |
| Refund | Refunds API | refunds.create({ payment_intent, amount }, { idempotencyKey }) |
| Dispute evidence | Disputes API | disputes.update(id, { evidence, submit: true }) |
| Marketplace payout | Connect | payment_intent_data: { application_fee_amount, transfer_data: { destination } } |
| Seller onboarding | Account Links | accountLinks.create({ account, type: 'account_onboarding', refresh_url, return_url }) |
| Time travel | Test clocks | testHelpers.testClocks.advance(clockId, { frozen_time }) |
| Outage recovery | Events API | events.list({ created: { gte }, type }) piped through the normal handler |
Answer: The redirect is a browser navigation that may never happen (closed tab, flaky mobile network) and can be forged by typing the URL. It also fires for delayed methods like bank debits that can still fail days later. The
checkout.session.completedandasync_payment_*events are signed, retried for days, and carry the authoritativepayment_status. The success page should only read order state that the webhook handler writes.
Q2: A customer reports being charged twice for one order. Walk through the likely causes and the fix.
Answer: Typical causes are a client double-click creating two sessions or intents, a server retry after a timeout with no idempotency key, or a per-attempt random key that changes on retry. Fix it in layers: derive the idempotency key from the order (
pi:${orderId}), store one PaymentIntent per order and reuse it, disable the pay button after submit, and add a uniqueness constraint on(order_id)for payments in your database. Refund the duplicate with its own idempotency key and add an alert on multiple succeeded intents per order.
Answer: Stripe computes HMAC-SHA256 over
timestamp + "." + raw bodyusing the endpoint'swhsec_secret and sendst=and one or morev1=values inStripe-Signature. The receiver recomputes the HMAC over the exact bytes, compares in constant time, and rejects timestamps outside a tolerance (300 seconds by default). This defeats forged events, body tampering and replay of captured deliveries. It does not prevent duplicates from Stripe itself, which is why dedupe onevent.idis still required.
Q4: Every webhook in production fails with "No signatures found matching the expected signature for payload" after a refactor. What happened?
Answer: Almost always the raw body was lost: a global
express.json()(or a framework body parser, API gateway transform or logging middleware) parsed and re-serialised the payload before verification. Other causes are using the CLI'swhsec_in production, using a test-mode secret for a live endpoint, or trimming whitespace. Mountexpress.raw({ type: 'application/json' })on the webhook route ahead of JSON parsing and verify the secret belongs to that endpoint and mode.
Answer: Persist
event.idin a table with a unique constraint inside the same database transaction as the business change, and treat a conflict as "already processed, return 200". Also make the transition itself idempotent (settingstatus = paidtwice is harmless; sending an email twice is not, so emails go through an outbox keyed by event id). If the handler fails, roll back the claim so Stripe's retry can succeed.
Answer: A
customer.subscription.updateddescribing a downgrade can arrive after a laterupdatedthat restored the original plan, silently reverting the customer. Defences: treat subscription events as notifications andretrievethe current object before writing, compare the event'screatedtimestamp with the last applied timestamp, and make state machines monotonic so a lateexpiredorprocessingcan never overwritepaidorsucceeded.
Answer: Stripe keeps retrying failed deliveries with backoff for up to three days in live mode, so many events will arrive on their own once the endpoint recovers. To be thorough, list events with
events.list({ created: { gte: outageStart } })filtered by the types you handle and feed each one through the same handler. Because the handler dedupes onevent.id, retried and replayed copies collapse to one side effect. Then run reconciliation against PaymentIntents to catch anything missed.
Answer: Checkout wins by default: Stripe maintains the page, wallets, localisation, tax, address collection, SCA and conversion optimisation, and your code shrinks to session creation plus webhooks. Choose the Payment Element when the payment step must live inside a bespoke multi-step flow, when you need full control of layout and validation, or in native mobile apps via the mobile SDKs. The trade is more UI, error and redirect handling owned by your team.
Q9: Describe the PaymentIntent lifecycle and why a failed attempt returns to requires_payment_method.
Answer: An intent moves from
requires_payment_methodtorequires_confirmation, possiblyrequires_actionfor 3DS, thenprocessing, thensucceeded(orrequires_capturewith manual capture), withcanceledas the abandonment path. A failed attempt is not terminal because the customer's intent to pay is unchanged; returning torequires_payment_methodlets them retry with another card on the same object. This keeps one intent per order, simplifies reconciliation and prevents a trail of orphaned failed intents.
Q10: An off-session renewal fails with authentication_required. What is the end-to-end recovery flow?
Answer: The issuer demanded SCA but the customer was not present. Mark the subscription or order as needing action, notify the customer by email with a link (the hosted invoice URL for subscriptions, or your own page), and on that page confirm the existing PaymentIntent with Stripe.js, which runs the 3DS challenge. Listen for
invoice.paidorpayment_intent.succeededto restore access. Reduce frequency by saving cards withusage: 'off_session'so issuers see the mandate up front.
Answer: Stripe stores the first request's result for that key (for at least 24 hours) and replays it for identical retries. A different parameter set under the same key returns an
idempotency_error, because the key is meant to identify one logical operation. This is a bug signal: usually two different operations share a key template, or a cart total changed between retries. Include the operation type and a stable domain id in the key, never just a user id.
Answer: Run a daily job that pulls balance transactions (and payouts with their constituent transactions) for the window, joins them to orders via
metadata.orderIdorclient_reference_id, and flags three classes: paid in Stripe but not in the DB (missed webhook), paid in the DB but not in Stripe (bug or fraud), and amount or currency mismatches. Store results, alert on any diff, and auto-heal the first class by replaying the corresponding event through the normal handler.
Answer: A refund is merchant-initiated through the API; it returns some or all of the money and generally does not return the processing fee. A dispute is cardholder-initiated through the issuer; Stripe immediately withdraws the amount plus a dispute fee, and you have a deadline to submit evidence. Disputes count against network monitoring thresholds while refunds do not, so refunding a clearly fraudulent charge early (for example on an early fraud warning) is often cheaper than fighting it.
Answer: Compute the refundable balance from
charge.amount_refunded(or your ledger) inside a row lock on the order, reject requests exceeding it, and give each refund request its own id used as the idempotency key. Persist the refund aspendingand update it fromrefund.updatedandcharge.refundedwebhooks, because refunds can fail asynchronously. This prevents two agents from each refunding the full balance and double-crediting the customer.
Answer:
trialingandactivegrant access;past_duemeans renewal failed and dunning is running, so most products keep access with a warning;incompletemeans the first payment needs action and should not grant access;incomplete_expired,canceledandunpaidremove access;pausedsuspends billing and access. Store the mapping in your entitlements table, update it from subscription and invoice webhooks, and check the table locally instead of calling Stripe on each request.
Answer:
invoice.paidfires whenever an invoice transitions to paid, including when it is paid out of band, settled by customer credit balance, or marked paid manually, whilepayment_succeededfocuses on a successful payment attempt. Keying entitlement extension oninvoice.paidcovers every way an invoice can become paid and avoids revoking access from customers who settled through an alternative route.
Q17: How does proration work, and how would you show the customer the exact charge before an upgrade?
Answer: When items change mid-period, Stripe creates proration line items crediting unused time on the old price and charging remaining time on the new one, roughly the price difference times the fraction of period left. Call
invoices.createPreviewwith the proposedsubscription_details.itemsandproration_behaviorto get the exactamount_due, display it, then apply the same change withsubscriptions.update. Pass a fixedproration_dateto both calls if you need the preview and the actual charge to match to the second.
Answer: Use a test clock: create a customer on the clock with a working test card, create the subscription, advance the clock past two renewals, then swap the default payment method to a declining test card and advance again. Wait for the clock to reach
ready, then assert oninvoice.payment_failed, thepast_duestatus, dunning emails and finally the configured terminal state. Test clocks are disposable, so delete them after the run to keep the sandbox clean.
Answer: Direct charges are created on the connected account, so that account is the merchant of record and bears fees, refunds and disputes while the platform takes an application fee. Destination charges are created on the platform and automatically transferred to one connected account; the platform bears fees and dispute liability by default. Separate charges and transfers decouple the payment from payouts so one charge can fund several sellers or pay out later, at the cost of the platform managing transfer timing and reversals.
Q20: A marketplace order is refunded after the seller's funds were transferred. Who pays and how do you handle it?
Answer: With destination charges or separate transfers, the refund comes from the platform balance by default. Pass
reverse_transfer: trueon the refund (andrefund_application_fee: trueif the fee should also return) to pull the money back from the connected account. If the seller's balance is insufficient, the account goes negative and future earnings cover it. Platforms bearing loss liability should hold reserves or delay payouts for risky sellers.
Answer: The seller returns to
return_urlwhenever they leave the Stripe-hosted flow, finished or not, and verification can also fail or become pending afterwards. The source of truth is the account object:charges_enabled,payouts_enabledandrequirements.currently_due, kept in sync viaaccount.updatedwebhooks. If the link expired or was reused, Stripe sends the seller torefresh_url, where you mint a fresh Account Link.
Answer: The client sends only identifiers (SKU, plan lookup key, quantity), and the server computes amounts from its own catalog or Stripe
Priceobjects before creating the session or intent. Validate quantities and currencies, ignore any client-supplied amount, and useObject.hasOwnstyle lookups so keys like__proto__cannot resolve. The companion tests assert that a request withamount: 1still produces the catalog price.
Answer: Raw card data must never reach your servers. Checkout, Payment Links, the Payment Element and the mobile SDKs collect card details in Stripe-hosted fields or iframes and return tokens or confirmations, leaving you with the lightest self-assessment. Logging request bodies on a page that hosts your own card inputs, or proxying card data through your backend, pulls your infrastructure into scope. Keep card entry inside Stripe components and serve pages over HTTPS with a sensible CSP.
Answer: Give each service a restricted key with only the permissions it needs: checkout writes Checkout Sessions, the refund service writes refunds and reads charges, analytics reads balance transactions. Store keys in a secrets manager, inject at runtime, and rotate on a schedule and on staff changes. The full secret key lives only in break-glass tooling. This limits blast radius if one service is compromised and makes Dashboard request logs attributable.
Answer: Roll the secret in the Dashboard with an expiry window for the old one; during the window Stripe signs each delivery with both secrets, producing multiple
v1signatures. Deploy the new secret (or temporarily accept either, by trying both in your verifier), confirm deliveries verify with the new one, and let the old secret expire. Never disable verification during rotation.
Answer: Card testers hammer session or intent creation with small amounts to validate stolen cards, producing bursts of declines. Immediately rate-limit session creation per IP, device and account, require login or CAPTCHA for anomalous traffic, raise minimum amounts, and enable or tighten Radar rules (block repeated CVC failures, velocity rules). Watch decline rate by
decline_codeas an alert signal. Each declined attempt can cost fees and harm your standing with networks.
Answer: As integers in minor units paired with an ISO currency code, exactly like Stripe. Never use floats; parse user-facing strings directly to integers and format only at the display edge with
Intl.NumberFormat. Respect zero-decimal (JPY) and three-decimal (KWD) currencies and per-currency minimum amounts. For fees and splits, round once at a defined point and make sure the parts sum exactly to the total.
Answer: The receiver verifies the signature, records the event (or enqueues it) transactionally, and returns 200 quickly, because slow responses cause timeouts and retries that amplify load. Workers consume from the queue idempotently, with retries, backoff and a dead-letter queue for poison messages. Expose metrics for queue depth and oldest-event age so a stuck worker becomes an alert, not a silent fulfillment gap.
Answer: When the final amount or availability is not known at checkout: hotels, rentals, made-to-order goods, marketplaces confirming seller acceptance. Authorise with
capture_method: 'manual'and capture up to the authorised amount later. Risks: authorisations expire (about seven days for many online card payments), uncaptured holds annoy customers, and capturing more than authorised is impossible. Run a scheduler that captures or cancels before expiry and alert on intents stuck inrequires_capture.
Answer: Read the changelog between your current and target versions, upgrade the SDK in a branch (the typed SDK pins its API version), fix type errors, and run the full suite, especially webhook tests with fixtures generated from the new version. Create a new webhook endpoint on the new version, or update the endpoint version during a quiet period, because event payload shapes follow the endpoint version. Roll out gradually, watching error rates and webhook verification and parse failures.
Answer: Inject the Stripe dependency behind small interfaces: a fake gateway for outbound calls and a real
constructEvent-based verifier for inbound events. Build fixture events shaped like real test-mode deliveries and sign them withstripe.webhooks.generateTestHeaderString, which is a pure HMAC function. Then assert on valid deliveries, tampered bodies, wrong secrets, stale timestamps and duplicates. This gives fast, deterministic CI while the CLI and test clocks cover contract-level checks.
Q32: A delayed payment method (SEPA, ACH) succeeds in Checkout but fails three days later. How should your system respond?
Answer:
checkout.session.completedarrives withpayment_status: 'unpaid', so the order should move toawaiting_paymentand not be fulfilled. Later,checkout.session.async_payment_succeededmoves it to paid and triggers fulfillment, whileasync_payment_failedmarks it failed, notifies the customer and releases inventory. If the business chooses to ship before settlement for trust reasons, model that explicitly as credit risk rather than letting it happen by accident.
Answer: Create the Customer at signup or first purchase in a transaction that stores
stripe_customer_idon the user row, using an idempotency key derived from the user id (customer:${userId}). Always passcustomerto Checkout instead ofcustomer_email, which can create a new customer per session. Duplicates cause split payment methods, duplicate subscriptions and confusing portals, so add a periodic job that detects customers sharing your user id in metadata.
Answer: Before creating a subscription Checkout Session, check your entitlements table for an active or trialing subscription and redirect to the Customer Portal for plan changes instead. Use an idempotency key per user and plan intent, reuse the existing Customer, and handle the race in the webhook by detecting a second active subscription for the same user and canceling (and refunding) the newer one. Plan changes should always go through
subscriptions.update, never a new subscription.
Answer: Define explicit per-currency amounts on Prices (multi-currency prices, or one price per currency) rather than converting on the fly, so customers see stable, rounded local prices. Choose presentment currency from the customer's location or preference, settle in currencies your account supports, and understand conversion fees when presentment and settlement differ. Store the currency on every order and never mix currencies in a single sum.
Answer: Usage is reported to billing meters and invoiced in arrears, so cancellation should usually finalise a last invoice for consumed usage rather than refund anything. For credits and goodwill, issue credit notes against invoices or customer balance credits instead of raw refunds, which keeps invoice accounting consistent. Disputed usage needs auditable usage records with idempotent ingestion keys so you can prove what was billed.
Answer: Local developers use their own sandbox or test mode with
stripe listen; CI uses offline tests with signed fixtures plus an isolated sandbox for contract tests; staging uses a dedicated sandbox with its own webhook endpoints and lookup-key-based prices; production uses live mode with restricted keys. Seed catalogs from code (scripts creating products and prices bylookup_key) so environments stay identical, and add a runtime guard that refuses live keys outside production.
Answer: The descriptor is what customers see on their bank statement. A recognisable brand name, and a dynamic suffix such as the product or order reference where supported, sharply reduces "I don't recognise this" disputes, which are often misunderstandings rather than fraud. Pair it with clear receipts and an easy refund path, then measure the dispute ratio before and after changes.
Answer: Remove personal data from your own systems, then delete or redact the Stripe Customer (deleting a customer detaches payment methods and cancels subscriptions). Financial records such as charges and invoices may need to be retained for legal and tax reasons, so the policy is redaction of personal fields where allowed rather than wholesale deletion. Document retention periods, keep the Stripe object ids in an audit log without personal data, and make the workflow idempotent.
Answer: Use Checkout or the Payment Element with destination charges (single-seller orders) or separate charges and transfers (multi-seller carts), with application fees computed server-side in minor units. Webhooks land on a thin verifier that persists events into a durable queue; idempotent workers drive an order state machine and a double-entry ledger. Idempotency keys derive from domain ids on every money-moving call. Sellers onboard via Account Links, gated on
charges_enabled. Daily reconciliation compares the ledger to balance transactions and payouts, Radar plus rate limits fight fraud, and test clocks, sandboxes and signed fixtures cover the test pyramid. Restricted keys per service and dashboards for webhook lag, decline and dispute rates complete the operational picture.
Contributions are welcomed! Fork the repository, create a feature branch, and submit a PR conforming to the unified curriculum architecture.
Released under the MIT License.