"Stripe works if you wire it up per the docs" — half true, half a trap. Code per the tutorial works on a sunny day. What breaks in production is a rainy day — when the network drops, Webhooks arrive twice, events arrive out of order, the moment the user closes the browser. For payments, "it works" isn't enough; the requirement is that even if it duplicates, even if the order goes wrong, even if it dies midway, the money state doesn't break.
This article is an implementation guide for implementing Stripe at reusable production quality. All code and behaviors are backed faithfully by the Stripe official documentation, with thicker decision axes (when, how, why) than the official docs. I myself implemented Stripe Webhook idempotency, ordering guarantees, PII redaction, and the bank-transfer-subscription state machine into a Next.js 16 monorepo in the financial-literacy education subscription learning platform, and handled B2B subscriptions with Stripe Connect in the METI-award lumber-DX SaaS. I leave here a generalization of "why I write it that way" from those implementations.
Baseline version: the current series of the Stripe Node SDK (the
stripepackage). Sincestripe-node v12, the SDK is auto-pinned to the API version at release time, and TypeScript types align with that API version (official: API versioning). The latest API version at the time of writing is2026-05-27.dahlia. This article's code is on this premise.
The scope of this article: this is "the general theory of implementing Stripe correctly." It's not a dissection of a specific repository. How I folded 6-system multi-channel billing, agency commissions, and type-safety discipline in a real product is written in the sister article Dissecting the architecture of a subscription learning platform. Avoiding duplication, here I concentrate on the principles and reusable types.
0. The Whole Picture: The "3 Invariants" That Don't Break Payments
The hard part of a Stripe implementation lies not in the number of features but in its nature as a distributed system. Your server and Stripe are separate machines, and the network between them is untrustworthy. So the invariants to uphold, pushed to the limit, are 3.
| Invariant | What threatens it | The means to uphold it | This article's chapter |
|---|---|---|---|
| Don't execute the same operation twice | Network resend, Webhook double delivery | Idempotency-Key + your own event.id de-duplication | §1・§3 |
| Don't believe a fake request | A third party's forged Webhook, a tampered amount | Signature verification (raw body) + decide the amount on the server | §2・§7 |
| Don't roll back state | Out-of-order event arrival | A state machine + reconcile against Stripe as the source of truth | §4・§5 |
Weave these 3 into the design from the start, and a Stripe implementation becomes surprisingly straightforward. Below, in order.
I can take on the implementation from this article as an engagement
Payment and subscription infrastructure (Stripe / serverless), from design to production
1. Why Payments Must Be Idempotent
1-1. The Network Guarantees Only "At Least Once"
Your server calls stripe.paymentIntents.create(), Stripe executes the charge, and returns a response — what happens if that response vanishes mid-network? Your code sees a timeout, judges "it failed," and retries. As a result, the same customer is charged twice.
This isn't an exception but the normal state of a distributed system. HTTP doesn't reliably tell you "whether the request arrived." So production payment code is written with the conception that "you can't guarantee exactly-once execution. But no matter how many times it runs, make the result one execution's worth (idempotent)."
1-2. HOW: Attach an Idempotency-Key to the POST to Stripe
Stripe provides idempotent requests for this problem. Attach a unique key to a POST request, and Stripe saves the result of the first request (status code and body) and returns the saved result for resends with the same key — the charge happens only once.
In the Node SDK, pass idempotencyKey in the 2nd argument (request options).
import Stripe from "stripe";
import { randomUUID } from "node:crypto";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// ❌ 危険:再送で二重課金しうる
await stripe.paymentIntents.create({ amount: 5000, currency: "jpy", customer });
// ✅ 安全:同じキーの再送は1回分として吸収される
await stripe.paymentIntents.create(
{ amount: 5000, currency: "jpy", customer },
{ idempotencyKey: idempotencyKeyForThisOperation }
);
Points to pin down from the official behavior:
- Valid only on POST. GET/DELETE are by definition already idempotent, so no key needed.
- The key is saved for at least 24 hours. Do retries within that window.
- The saved result is returned regardless of success/failure (a
500is reproduced too). So don't expect "it was 500 last time so it'll go through this time." A retry where you don't want to make a failure permanent needs the judgment to change the key. - With the same key but different parameters, Stripe returns an error (misuse detection).
- The key is a random string with sufficient entropy like a V4 UUID (max 255 chars). Don't use sensitive info like an email or a guessable value.
1-3. WHY: Tie the Key to the "Operation," Don't Generate It Per Request
This is the crux the tutorials don't teach. If you freshly generate idempotencyKey: randomUUID() per call, idempotency becomes meaningless. Because it becomes a different key per resend and goes through as a different charge.
Make the key "a deterministic value uniquely representing this business operation." For example, for "a charge to order order_123," derive it from the operation's identity like charge:order_123:v1. This way, a charge to the same order is one execution no matter how many times you hit it. In my subscription foundation, I deterministically assemble the commission ledger's idempotency key with scope:period:currency:revision (details). The same principle.
The case of implementing idempotency on AWS (DynamoDB conditional writes) I wrote in a separate article, Achieving payment idempotency and zero downtime with DynamoDB. The Stripe-side idempotency (this key) and your own DB-side idempotency are different layers, and both are needed. The next chapter handles "your own side."
2. Do Webhook Signature Verification Correctly (Raw Body Is Everything)
2-1. Why Signature Verification Is Mandatory
A Webhook endpoint is a POST receiver exposed to the internet. Without any verification, a third party could forge checkout.session.completed, throw it in, and activate your product for free. So Stripe attaches a signature to all Webhooks, and you verify it before processing.
2-2. HOW: Pass the Raw Body to stripe.webhooks.constructEvent
What the official signature verification requires is 3 arguments.
- The raw (unprocessed) request body — the byte sequence before parsing. The value the framework converted to JSON is unusable.
- The
Stripe-Signatureheader — the formt=timestamp,v1=signature. - The endpoint secret — a value starting with
whsec_. Hold it in an environment variable.
The biggest pitfall is the raw-body requirement. Many frameworks auto-JSON-parse the received body, changing whitespace and order. Verifying the signature with a parsed value always fails. Because the signature is computed against the byte sequence.
For Express
Use express.raw() only on the Webhook route, and place it before express.json() (official quickstart).
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const app = express();
// このルートだけ raw Buffer で受ける(express.json() より前)
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["stripe-signature"];
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // ← Buffer のまま渡す。JSON.parse してはいけない
signature as string,
endpointSecret
);
} catch (err) {
// 署名不一致=偽装か設定ミス。即 400 を返して処理しない
console.warn("Webhook signature verification failed.", (err as Error).message);
return res.sendStatus(400);
}
// ここから先は「Stripe が確かに送った」と信頼できる
res.json({ received: true }); // ★ まず素早く 2xx(理由は §3)
enqueue(event); // 重い処理は非同期キューへ
}
);
app.use(express.json()); // 他ルートは通常どおりJSON
For Next.js App Router (Route Handler)
In Next.js, get the raw text with await req.text(). Use req.json() and it becomes parsed and verification fails.
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text(); // ★ raw 文字列。req.json() ではない
const signature = req.headers.get("stripe-signature");
if (signature === null) return NextResponse.json({ error: "no signature" }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
} catch (err) {
console.warn("Webhook signature verification failed.", (err as Error).message);
return NextResponse.json({ error: "invalid signature" }, { status: 400 });
}
await handleStripeEvent(event); // 後述の冪等ハンドラ
return NextResponse.json({ received: true });
}
WHY raw body:
constructEventrecomputes the HMAC from the received byte sequence and the secret, and matches it againstStripe-Signature'sv1=. Change even one byte and it doesn't match. 90% of "for some reason signature verification fails only in production" is caused by an upstream middleware touching the body.
3. The Golden Rule: Webhooks Are "Out-of-Order, At-Least-Once"
This is the most important chapter in this article. You need to internalize, as the premise of design, the Webhook delivery semantics the Stripe official docs state plainly.
At-least-once: "A Webhook endpoint may receive the same event multiple times." In production it's retried with exponential backoff for up to 3 days.
No ordering guarantee: "Stripe does not guarantee delivery of events in the order in which they are generated." Even if subscription creation is generated in the order
customer.subscription.created→invoice.created→invoice.paid, arrival can be reversed.
So your handler must be (a) safe even if the same event arrives twice (idempotent), and (b) not break state even if an old event arrives after a new one (order-independent).
3-1. (a) Your Own Idempotency: Record event.id in the Same Transaction as the Business Update
Separately from Stripe-side idempotency in §1, you also reject duplicates on the Webhook-handling side. The official recommendation is simple: "log the event IDs you've processed, and don't process already-logged events."
Put a unique constraint on event.id (evt_...): if the insert succeeds it's the first time, if it collides you skip. What matters decisively is that the event-ID record and the business update commit in the same transaction.
import { Prisma } from "@prisma/client";
type Tx = Prisma.TransactionClient;
// Commit the receipt record and the business update in one transaction
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
await prisma.$transaction(
async (tx) => {
// (1) Receipt record. On PostgreSQL, skipDuplicates becomes ON CONFLICT DO NOTHING.
// If a row for this event.id already exists, count is 0 (a return value, not an exception)
const { count } = await tx.stripeWebhookEvent.createMany({
data: [{ stripeEventId: event.id, eventType: event.type }],
skipDuplicates: true,
});
if (count === 0) {
console.info(`duplicate event skipped: ${event.id}`);
return; // Duplicate delivery: COMMIT without doing anything and return 2xx
}
// (2) Business update. If it fails, (1) rolls back too and Stripe's retry reprocesses it
await dispatch(event, tx);
},
{ timeout: 10_000 }, // §3-2 reads from the Stripe API, so allow more than the 5 s default
);
// Let exceptions propagate -> return 5xx and let Stripe retry
}
WHY the same transaction: if you commit the receipt record first, in its own transaction, and the business update then fails, the retry is rejected as "already logged = already processed" and that event is never processed. If you commit the business update first and record afterwards, a crash in between means the retry processes it twice. Fold both writes into one commit and neither can happen. This receiving-side design (a Transactional Inbox), including what happens when the same event arrives concurrently, is reproduced against PostgreSQL in Implementing webhook idempotency with a Transactional Inbox.
WHY lean on a DB constraint: if the app layer does "SELECT, and INSERT if absent," then when duplicate deliveries arrive simultaneously, both SELECTs see "absent" and the event is double-processed (TOCTOU). A unique constraint has the DB arbitrate the race atomically. A second concurrent INSERT waits until the first transaction finishes: if the first commits,
countis 0; if it rolls back, the second takes over the processing.
WHY not detect duplicates by exception (P2002): if you write "on a unique violation (P2002), treat it as a duplicate and return 2xx", then business-side unique violations inside
dispatch(such as the per-invoice constraint recommended below) are treated as "duplicates" too. The whole transaction rolls back, taking the receipt record with it, and you return 2xx — so the event is never processed again, which is exactly the permanent loss you set out to prevent. Detect duplicates from the return value of the receipt INSERT, and write business-side writes as upserts or ON CONFLICT so they don't throw.
Revision note (2026-10-04): the first version of this article recorded the event ID and then ran the business logic as a separate commit. Because that loses events permanently as described above, it now commits both in one transaction.
Stripe's docs also note that in some cases two separate Event objects are generated for the same fact, and suggest identifying those duplicates by the ID in data.object together with event.type. Event-ID de-duplication cannot catch that, so also put unique constraints on the business-side writes (for example, one payment record per invoice ID) and make the handler itself idempotent.
3-2. (b) Order-Independent: Decide with State Transitions and Fresh State, Not created
De-duplication alone doesn't solve ordering. If customer.subscription.updated fires twice and the newer one is processed first and the older one arrives later, storing the payload as-is rolls the subscription state back.
The tempting fix is "compare event.created and discard older events," but Stripe's documentation explicitly rules this out (as of October 2026). Because created is recorded in seconds and distinct events can share a timestamp, it says not to use created to determine event order or whether you've already processed an event.
Revision note (2026-10-04): the first version recommended rejecting rollbacks with
event.created. It has been rewritten to follow the documentation above.
Instead, use one of two approaches depending on the event:
- Where the change can be written as a monotonic state transition, write it that way. For
invoice.paid, "advance topaidonly if not alreadypaidorvoid" gives the same final state in any arrival order. - Where it isn't monotonic (a subscription's
statusmoves back and forth betweenactiveandpast_due), treat the event as a signal that something changed, and fetch the latest object from the Stripe API and store that. Stripe's docs likewise suggest using the API to retrieve related objects when an event arrives first.
The second approach has one trap: if two syncs for the same subscription run concurrently, the result fetched earlier (the older one) can be saved last. Serialize fetch-then-save per subscription.
import { createHash } from "node:crypto";
// Map a subscription ID to a PostgreSQL advisory-lock key (bigint)
function advisoryKey(namespace: string, id: string): bigint {
return createHash("sha256").update(`${namespace}:${id}`).digest().readBigInt64BE(0);
}
// The webhook is a trigger to look; the value we store is the latest one from Stripe
async function syncSubscription(tx: Tx, subscriptionId: string): Promise<void> {
// Serialize "fetch -> save" for one subscription. Released automatically on COMMIT / ROLLBACK
await tx.$queryRaw`SELECT 1 FROM pg_advisory_xact_lock(${advisoryKey("stripe-subscription", subscriptionId)})`;
const fresh = await stripe.subscriptions.retrieve(subscriptionId);
await tx.subscription.upsert({
where: { stripeId: fresh.id },
create: { stripeId: fresh.id, status: fresh.status },
update: { status: fresh.status },
});
}
Because the lock is taken before the fetch, a sync that acquires the lock later always fetches after the earlier sync has saved, and so sees state at least as new. pg_advisory_xact_lock is released automatically when the transaction ends, so it can't leak (usage and caveats in the PostgreSQL advisory lock guide).
Trade-off: this code calls the Stripe API while holding a transaction and a lock. It is a read (GET), so a rollback leaves no external side effect, but if Stripe is slow, other syncs for the same subscription wait and a DB connection stays checked out. Only events for the same subscription wait, but at high webhook volume switch to committing only the receipt record, returning 2xx, and running the sync in an asynchronous worker (§3-4).
3-3. The Ultimate Safety Net: "Reconcile" Against Stripe as the Source of Truth
A webhook is a notification, not the truth itself. The re-fetch in §3-2 only runs when an event arrives, so it cannot recover from outages longer than the retry window (up to three days) or from events lost to a bug.
For state that really matters (is the subscription active, has it been paid), compare against the Stripe API periodically, and when you find drift, repair it through the same canonical write path as §3-2. A cursor that is guaranteed to cover every row within a bounded time, a classification that never counts unverifiable state as healthy, and the line between drift you can fix automatically and drift a human should review are covered in Designing reconciliation.
A cost caution: reconciliation means extra API calls, and Stripe's rate limits are shared with your production payment traffic. Hitting everything on every event is wasteful. Give the reconciliation job only a fraction of the limit, and prioritise states where money moves, such as payment confirmation, and cases where you detected a contradiction (observability + cost efficiency).
3-4. Why "Quickly 2xx → Async Processing"
The official docs state plainly, "before complex logic that could cause a timeout, quickly return 2xx." There are 2 reasons.
- If 2xx is slow, Stripe retries (if success doesn't arrive, it's treated as a failure). Do heavy processing synchronously and you induce timeout → retry → double delivery yourself.
- When Webhooks surge like a month-start bulk renewal, synchronous processing clogs the endpoint. The official recommendation is "process received events with an asynchronous queue."
The design is "signature verification → record event.id as unprocessed → return 2xx immediately → heavy processing to a queue/background." Here the record is not proof that the event was processed; it is proof that you have taken responsibility for processing it. The worker commits the update that marks the record processed in the same transaction as the business update (for the same reason as §3-1). On serverless (Vercel etc.), do light processing on the spot and offload heavy processing to a dedicated job/Queue.
4. Treat the Subscription Lifecycle as a "State Machine"
Scatter the subscription's status around with if (status === "active"), and a missed branch will surely become an accident. The right way is to fold the subscription statuses Stripe defines into one place as a state machine, and fix "how to handle access rights in each state" in a table.
| status | Meaning (official) | The product's access right | What to do |
|---|---|---|---|
trialing | During the trial period. Usable even before payment. Goes to active on the first payment. | Grant | Open full features |
active | Normal. The required payments are complete. | Grant | Open full features |
incomplete | The first payment is needed within 23 hours (including 3DS-etc. auth waits). | Hold | Don't open yet. Wait for completion |
incomplete_expired | The first payment wasn't completed within 23 hours and expired. No charge. | Not allowed | Creating a new subscription is needed |
past_due | The latest invoice's payment failed/wasn't attempted. Invoices keep being generated. Retries continuing. | Restrict/notify | Dunning. Try to recover with Smart Retries |
unpaid | The latest invoice is unpaid. Retries are exhausted. | Immediate revocation | Stop access |
canceled | Canceled. Terminal, irreversible. | Revoke | Tidy up entitlements |
paused | When there's no payment method at trial end and pause is set. No invoice generation. | Hold | Wait for a payment method to be added → resume |
Decision axes the official docs especially emphasize:
- You may grant access only when
activeortrialing. Open early atincompleteand it's used with payment incomplete. past_dueandunpaidare different things.past_dueis still retrying (notify with a grace period).unpaidis retries exhausted, so immediate revocation. Conflate them and you invite either default or, conversely, too-early cutoff of a legitimate customer.canceledis irreversible. Re-subscription needs a new subscription, and you can't reuse a canceled one.
Harden this with types. With a union type + exhaustiveness check, make it a state where "a missed branch falls over at compile time when a new status is added" (for the reason to avoid enum, see the sister article's NeverError).
type Access = "grant" | "restrict" | "revoke" | "pending";
// status → アクセス権 の写像を1箇所に集約(状態機械)
function accessForStatus(status: Stripe.Subscription.Status): Access {
switch (status) {
case "trialing":
case "active":
return "grant";
case "incomplete":
case "paused":
return "pending";
case "past_due":
return "restrict";
case "incomplete_expired":
case "unpaid":
case "canceled":
return "revoke";
default:
// status が増えてここを更新し忘れると型エラーになる(網羅保証)
return assertNever(status);
}
}
function assertNever(value: never): never {
throw new Error(`unhandled subscription status: ${String(value)}`);
}
4-1. Which Event Updates What
The events that actually matter in subscription operation are limited.
// tx is the same transaction as the receipt record in §3-1; all business updates run inside it
async function dispatch(event: Stripe.Event, tx: Tx): Promise<void> {
switch (event.type) {
case "checkout.session.completed":
// Checkout completed. In subscription mode, fetch the subscription and apply it
await onCheckoutCompleted(event.data.object as Stripe.Checkout.Session, tx);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
// The single entry point for the state machine. Store the latest state fetched from Stripe (§3-2)
await syncSubscription(tx, (event.data.object as Stripe.Subscription).id);
break;
case "invoice.paid":
// A successful renewal. Extend access (a unique constraint on the invoice ID prevents double extension)
await extendEntitlement(event.data.object as Stripe.Invoice, tx);
break;
case "invoice.payment_failed":
// ★ Ignoring this is the most frequent accident. Start dunning
await startDunning(event.data.object as Stripe.Invoice, tx);
break;
default:
// 未知イベントも 2xx で受ける(400 を返すとリトライ地獄)
console.info(`unhandled event type: ${event.type}`);
}
}
WHY always handle
invoice.payment_failed: cards normally expire and run out of balance. Ignore this and apast_dueuser is left alone, silently progressing tounpaid→canceled. The role of this event is to trigger a dunning email and staged access restriction. In my subscription foundation, I make the bank-transfer-side dunning idempotent with a "sent set" to prevent double sending.
5. Checkout and Customer Portal: Let Stripe Host the Hard Parts
Building the payment UI yourself means shouldering PCI compliance, 3DS, and support for various payment methods all by yourself. Stripe Checkout and Customer Portal are mechanisms that have Stripe host the "hard part." First considering whether these suffice is the right answer.
5-1. Checkout vs. Direct Payment Intents Implementation
| Viewpoint | Stripe Checkout (hosted) | Direct Payment Intents (Elements, etc.) |
|---|---|---|
| Implementation cost | Low (just redirect) | High (UI, state, errors yourself) |
| PCI compliance burden | Minimal (SAQ A) | Large |
| 3DS / various payment methods | Stripe auto-handles | Handle yourself |
| UI freedom | Medium (about theming) | High |
| Suited to | Most SaaS billing / subscriptions | When a unique payment experience is mandatory |
The judgment is simple. Unless a special payment experience is a requirement, choose Checkout. There's no reason to shoulder non-essential complexity for the sake of freedom.
5-2. Creating a Checkout Session
Per the official quickstart, create with stripe.checkout.sessions.create and redirect to session.url. For a subscription, mode: "subscription", for a one-time purchase, mode: "payment". Pass a server-managed Price ID in line_items (for the reason not to pass the amount directly, see §7).
// app/api/checkout/route.ts(サーバー側でのみ実行)
export async function POST(req: NextRequest) {
const customerId = await requireCustomerId(); // 認証済みユーザー → Stripe Customer
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [{ price: process.env.STRIPE_PRICE_PRO_MONTHLY!, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/billing/cancel`,
// セッションに自分のユーザーIDを紐づけ、Webhookで突合できるようにする
client_reference_id: await currentUserId(),
});
return NextResponse.json({ url: session.url });
}
Confirm completion not on the redirect-target page but always with the Webhook's checkout.session.completed. Because, even if the user closes the browser before reaching success_url, the charge is established. Leaning fulfillment (provisioning) to the Webhook is the official manner.
5-3. Customer Portal: Hand Over Cancellation and Payment-Method Changes Wholesale
Customer Portal lets customers self-service payment-method updates, plan changes, cancellation, and invoice viewing on a Stripe-hosted screen. The "cancellation flow" that's hell to build yourself can be prepared with config alone.
const portal = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.APP_URL}/billing`,
});
// portal.url へリダイレクト。セッションは5分で失効する点に注意
When a cancellation or change happens in the Portal,
customer.subscription.updated/deletedflies in via the Webhook after all. The source of truth is, in the end, the Webhook + API, and the Portal is merely the entrance — keep this consistency and the design doesn't crumble.
6. Testing: Flow Events Locally with the Stripe CLI
Payments are a domain where "hitting production by hand to check" isn't permitted. With the Stripe CLI, forward Webhooks to local and fire events intentionally to verify.
# 1) ログイン(ブラウザで認可)
stripe login
# 2) Stripe から localhost へ Webhook を転送
# 起動時に whsec_ で始まる「署名シークレット」が表示される
stripe listen --forward-to localhost:3000/api/stripe/webhook
# > Ready! Your webhook signing secret is whsec_xxxxxxxx
# 3) 表示された whsec_ を STRIPE_WEBHOOK_SECRET に設定して dev を起動
# 4) 任意のイベントを発火させて、ハンドラを叩く
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.updated
The whsec_ that stripe listen emits is a local-only secret, separate from the production dashboard's. Using it lets you verify the real signature-verification path locally as-is (avoiding the shortcut of disabling just the verification).
Test Cards (official testing)
| Card number | The behavior it simulates |
|---|---|
4242 4242 4242 4242 | Success (Visa) |
4000 0000 0000 9995 | Declined for insufficient funds |
4000 0025 0000 3155 | 3DS authentication needed (requires authentication) |
4000 0000 0000 0341 | Card registration succeeds, but the subsequent charge fails |
Reproduce locally
incomplete→ auth complete →activewith4000 0025 0000 3155, andinvoice.payment_failed→past_duewith4000 0000 0000 9995, before shipping to production. Each branch of the state machine (§4) can thus be verified in an observable form.
7. Security: Don't Trust the Client
Payments are an attack target. The principle is one — don't use values coming from the client for money-related judgments.
7-1. Don't Receive the Amount from the Client
The most dangerous anti-pattern is creating a Checkout/charge with the amount the front end sent. An attacker tampers with the request and can buy the Pro plan for amount: 100 (1 yen).
// ❌ 致命的:クライアントの金額を信じる
await stripe.checkout.sessions.create({
line_items: [{ price_data: { unit_amount: body.amount, /* ... */ }, quantity: 1 }],
});
// ✅ 正:サーバーが持つ Price ID を使う。価格はStripe側で確定
await stripe.checkout.sessions.create({
line_items: [{ price: PRICE_IDS[body.plan], quantity: 1 }], // planは許可リストで検証
});
Create the price as a Price in the Stripe dashboard/API, and the code references only the Price ID. What the client may send is only an identifier of "which plan," and that identifier too is validated with an allowlist.
7-2. Validate the Boundary with Zod and Narrow the Type
Validate external input (API requests, Webhook payloads) at the boundary with Zod, and flow only trustworthy types inside.
import { z } from "zod";
const checkoutInput = z.object({
plan: z.enum(["pro_monthly", "pro_yearly"]), // 許可リスト=改ざん耐性
});
export async function POST(req: NextRequest) {
const parsed = checkoutInput.safeParse(await req.json());
if (!parsed.success) return NextResponse.json({ error: "bad request" }, { status: 400 });
const priceId = PRICE_IDS[parsed.data.plan]; // 既知の値だけが通る
// ...
}
7-3. Keys with Least Privilege, Don't Keep PII
- API keys in environment variables.
STRIPE_SECRET_KEYis server-only (never put in the client bundle). The signing secretSTRIPE_WEBHOOK_SECRETlikewise. - To limit use, narrow to least privilege with a restricted API key (don't give a full-privilege key to a read-only service).
- A Webhook's raw payload contains PII like card, email, and address. If you save it for debugging, recursively redact PII before saving. In my subscription foundation, I replace keys like
billing_details/card/emailwith<redacted>before saving (implementation). - In logs, emit only the tags needed for tracking like
event.idandevent.type, and don't leak the raw data of PII or amounts.
8. Common Pitfalls
The ones that actually appear frequently in implementation reviews, in order of impact.
- ❌ Verifying the signature with parsed JSON → always fails, or you give up verification and disable it (worst). Use the raw body (
req.text()/express.raw()) (§2). - ❌ Charging with the amount the client sent → in the red from price tampering. Hold the Price ID on the server (§7-1).
- ❌ Not de-duplicating
event.id→ double provisioning / double charging on double delivery. Reject with a unique constraint (§3-1). - ❌ Ignoring
invoice.payment_failed→ leaving expiry alone, no dunning, silently progressing to cancellation (§4-1). - ❌ Assuming the order → state rolls back on an old
subscription.updated. Stripe advises against deciding order withevent.created; write a state transition, or fetch the latest state and save it serially (§3-2). - ❌ Committing the receipt record and the business update separately → record first and a failed handler loses the event for good; record last and a crash double-processes it. Put both in one transaction (§3-1).
- ❌ Heavy processing synchronously then 2xx → timeout → Stripe retries → you induce double delivery yourself. 2xx first, async after (§3-4).
- ❌ Fulfilling on the
success_urlpage → un-provisioned if the user leaves. Confirm with the Webhook (§5-2). - ❌ Returning 400 to an unknown event → retries continue for 3 days. Receive unhandled events with 2xx too (§4-1).
- ❌ Randomly generating
idempotencyKeyeach time → idempotency is nullified. Derive deterministically from the operation's identity (§1-3).
9. The Crux of Production Operation (Cross-Cutting Design)
Build the operational side with the same priority as features.
- Observability: log
event.id/event.type/ the processing result in all Webhook processing. Connect handler failures to alerts (don't silently swallow payment failures). Being able to later trace "at which event did the state go wrong" is the lifeline of payment debugging. - Resilience: the Stripe side retries for up to 3 days. On your side too, express the intent in the returned code — return 5xx for a transient failure inside the handler (a temporary DB error, etc.) to ride the retry, and 2xx + record for a permanent failure (invalid data) to avoid retry hell. Evacuate events you can't process to a dead-letter, in a state a human can pick up.
- Type safety: narrow
Stripe.Eventwithswitch (event.type), and narrowevent.data.objectto each event's type. The boundary is Zod. Avoid the abuse ofassince it's a lie to the type. - Cost efficiency: do
reconcile(re-fetch from Stripe) only at the necessary moment. Don't hit it on every event. Sweep old events from the idempotency-record table periodically to curb storage, but keep them well beyond the webhook retry window (up to three days in live mode). Any shorter, and a retry that arrives on day three is no longer recognised as a duplicate and gets processed again (I usually keep about 30 days, which also covers incident investigation).
Summary: Stripe Becomes Straightforward When Designed on a "Doesn't-Break" Premise
Implementing Stripe at production quality means absorbing a distributed system's uncertainty with design. The key points in 5 lines.
- Idempotency in two layers. To Stripe, an
Idempotency-Key(deterministically derived from the operation); your own side, a unique constraint onevent.id. Doesn't break on resend. - Signature verification with the raw body. Pass the unprocessed body to
stripe.webhooks.constructEvent. Don't let an upstream parser touch the body. - Webhooks are out-of-order, at-least-once. Commit the receipt record and the business update in one transaction. Don't decide order with
created: write state transitions or fetch and save the latest state, and reconcile important values against Stripe periodically. 2xx first, heavy processing async. - The subscription is a state machine. Fix
active/past_due/unpaid/canceledin a table, separatingunpaid= immediate revocation andpast_due= dunning, and exhaust them with types. - Don't trust the client. Confirm the amount on the server with a Price ID, the boundary with Zod, keys with least privilege, PII redacted before saving.
The difference between "working payments" and "a payment foundation that survives production with multiple people" lies precisely in judgments like these, one by one. Its real example is the subscription learning platform (multi-channel billing, idempotent payments, agency commissions built in a Next.js 16 monorepo), the origin of this article's principles. The implementation deep-dive is in the architecture dissection, and the story of achieving payment idempotency on the AWS side is in the DynamoDB article.
If you're considering new development or a rebuild involving Stripe-based subscription billing, payment reliability, or type-safe domain design, I'll take it on at this standard, from requirements definition through implementation and operation. Feel free to reach out via contact.