# Implementing Stripe Webhooks and Idempotency at Production Quality: Signature Verification, Out-of-Order / At-Least-Once Delivery Resistance, the Subscription State Machine

> A 'doesn't-break' payment implementation guide faithful to the Stripe official documentation. Explained with working TypeScript code: idempotent API calls with an Idempotency-Key, Webhook signature verification (raw body required) and resistance to double delivery / out-of-order, the technique of designing the subscription lifecycle as a state machine, and verification with the Stripe CLI.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: Stripe, TypeScript, B2B SaaS, アーキテクチャ設計, Next.js, 決済
- URL: https://tomodahinata.com/en/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions
- Category: Payments & billing

## Key points

- The invariants that don't break payments boil down to 3: don't execute twice, don't believe fake requests, don't roll back state
- Idempotency is two-layered. The POST to Stripe uses an Idempotency-Key deterministically derived from the operation; your own side de-duplicates with a unique constraint on event.id
- Webhook signature verification always passes the raw body to constructEvent. Not letting the upstream parser touch the body is the key to avoiding the pitfall
- Webhooks presuppose out-of-order, at-least-once delivery. Reject rollbacks with event.created, return 2xx first, and make heavy processing asynchronous
- Fold the subscription into one place as a state machine, separating past_due = dunning and unpaid = immediate revocation, and harden it with a union type + exhaustiveness check

---

"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](https://docs.stripe.com), 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](/case-studies/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 `stripe` package). Since `stripe-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](https://docs.stripe.com/api/versioning)). The latest API version at the time of writing is `2026-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](/blog/subscription-platform-billing-idempotency-type-safety). 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.

---

## 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](https://docs.stripe.com/api/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).

```ts
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 `500` is 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](/blog/subscription-platform-billing-idempotency-type-safety)). 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](/blog/dynamodb-payment-reliability-idempotency-zero-downtime). 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](https://docs.stripe.com/webhooks/signature) requires is 3 arguments.

1. **The raw (unprocessed) request body** — the byte sequence before parsing. The value the framework converted to JSON is **unusable.**
2. **The `Stripe-Signature` header** — the form `t=timestamp,v1=signature`.
3. **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](https://docs.stripe.com/webhooks/quickstart)).

```ts
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.

```ts
// 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**: `constructEvent` recomputes the HMAC from the received byte sequence and the secret, and matches it against `Stripe-Signature`'s `v1=`. 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](https://docs.stripe.com/webhooks) 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: De-Duplicate with `event.id`

Separate from §1's Stripe-side idempotency, **on the Webhook-processing side too**, reject duplicates. The official recommendation is simple — "**record the processed [event ID](https://docs.stripe.com/api/events/object#event_object-id), and don't process an already-recorded event.**"

Put a **unique constraint** on `event.id` (`evt_...`); if you could insert, it's the first time; if it collided, skip. The key is doing "record first, process after" atomically.

```ts
// 受信イベントを一意制約で記録 → 初回だけ処理する
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
  // INSERT が衝突したら過去に処理済み＝何もしない（冪等）
  const isFirstTime = await tryRecordEvent(event.id, event.type, event.created);
  if (!isFirstTime) {
    console.info(`duplicate event skipped: ${event.id}`);
    return;
  }
  await dispatch(event);
}

// 例：Prisma。stripeEventId に @unique を張っておく
async function tryRecordEvent(id: string, type: string, created: number): Promise<boolean> {
  try {
    await prisma.stripeWebhookEvent.create({
      data: { stripeEventId: id, eventType: type, eventCreated: created },
    });
    return true;
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
      return false; // 一意制約違反＝二重配信
    }
    throw e;
  }
}
```

> **WHY rely on a DB constraint**: do "SELECT and if absent INSERT" at the app layer, and when double deliveries arrive **simultaneously**, both judge "absent" on the SELECT and double-processing happens (TOCTOU). **A unique constraint has the DB atomically arbitrate the race**, so it's race-resistant. This is the same idea as the first layer I implemented as `StripeWebhookEvents.stripeEventId @unique` in the sister article.

### 3-2. (b) Order-Independent: Reject Rollbacks with `event.created`

De-duplication alone doesn't solve the **ordering problem.** If `customer.subscription.updated` flies twice, the newer is processed first, and then **the older arrives**, the subscription state rolls back to an old value.

The countermeasure is to hold on the target record "**the `created` time of the last processed event**" and discard events older than it.

```ts
async function applySubscriptionUpdate(sub: Stripe.Subscription, eventCreated: number) {
  const current = await prisma.subscription.findUnique({ where: { stripeId: sub.id } });

  // 既存より古いイベントなら無視（巻き戻り防止）
  if (current && current.lastEventCreated !== null && eventCreated < current.lastEventCreated) {
    console.info(`stale event ignored for ${sub.id} (created=${eventCreated})`);
    return;
  }

  await prisma.subscription.upsert({
    where: { stripeId: sub.id },
    create: { stripeId: sub.id, status: sub.status, lastEventCreated: eventCreated },
    update: { status: sub.status, lastEventCreated: eventCreated },
  });
}
```

### 3-3. The Ultimate Safety Net: "Reconcile" Against Stripe as the Source of Truth

A Webhook is a **notification**, not **the truth itself.** A truly important state (whether the subscription is active) is, if in doubt, most robust to **re-fetch from Stripe's API.** Use the Webhook as a "signal that there was a change," and confirm with the API.

```ts
// Webhook は「見に行くきっかけ」。確定値は Stripe から取得
async function reconcileSubscription(subscriptionId: string) {
  const fresh = await stripe.subscriptions.retrieve(subscriptionId);
  await applySubscriptionUpdate(fresh, fresh.created);
}
```

> **A cost caution**: reconciliation is an additional API call. **Hitting it on every event is wasteful.** The realistic solution is the contrast of processing basically with the Webhook payload, and **reconciling only when you detect a contradiction, or at the moment money moves like a payment confirmation** (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.

1. **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.**
2. **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` → return 2xx immediately → heavy processing to a queue/background.**" 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](https://docs.stripe.com/billing/subscriptions/overview) 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 `active` or `trialing`.** Open early at `incomplete` and it's used with payment incomplete.
- **`past_due` and `unpaid` are different things.** `past_due` is still retrying (notify with a grace period). `unpaid` is **retries exhausted**, so **immediate revocation.** Conflate them and you invite either default or, conversely, too-early cutoff of a legitimate customer.
- **`canceled` is 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`](/blog/subscription-platform-billing-idempotency-type-safety)).

```ts
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.

```ts
async function dispatch(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case "checkout.session.completed":
      // Checkout完了。subscriptionモードなら subscription を取得して反映
      await onCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
      break;
    case "customer.subscription.created":
    case "customer.subscription.updated":
    case "customer.subscription.deleted":
      // 状態機械を回す唯一の入口に集約
      await applySubscriptionUpdate(
        event.data.object as Stripe.Subscription,
        event.created
      );
      break;
    case "invoice.paid":
      // 継続課金の成功。利用期間を延長
      await extendEntitlement(event.data.object as Stripe.Invoice);
      break;
    case "invoice.payment_failed":
      // ★ ここを無視するのが最頻出の事故。督促(dunning)を起動
      await startDunning(event.data.object as Stripe.Invoice);
      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 a `past_due` user is left alone, silently progressing to `unpaid`→`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](https://docs.stripe.com/checkout/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).

```ts
// 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](https://docs.stripe.com/customer-management) 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.

```ts
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/deleted` flies 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](https://docs.stripe.com/stripe-cli), forward Webhooks to local and fire events intentionally to verify.

```bash
# 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](https://docs.stripe.com/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 → `active`** with `4000 0025 0000 3155`, and **`invoice.payment_failed` → `past_due`** with `4000 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).

```ts
// ❌ 致命的：クライアントの金額を信じる
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.

```ts
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_KEY` is **server-only** (never put in the client bundle). The signing secret `STRIPE_WEBHOOK_SECRET` likewise.
- 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`/`email` with `<redacted>` before saving ([implementation](/blog/subscription-platform-billing-idempotency-type-safety)).
- In logs, emit **only the tags needed for tracking** like `event.id` and `event.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`. Reject with `event.created` (§3-2).
- **❌ Heavy processing synchronously then 2xx** → timeout → Stripe retries → you induce double delivery yourself. 2xx first, async after (§3-4).
- **❌ Fulfilling on the `success_url` page** → 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 `idempotencyKey` each 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.Event` with `switch (event.type)`, and narrow `event.data.object` to each event's type. The boundary is Zod. Avoid the abuse of `as` since 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 with a TTL** to curb storage (since the Stripe-side key save is 24 hours, holding longer is often unnecessary for reconciliation purposes).

---

## 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.

1. **Idempotency in two layers.** To Stripe, an `Idempotency-Key` (deterministically derived from the operation); your own side, a unique constraint on `event.id`. Doesn't break on resend.
2. **Signature verification with the raw body.** Pass the unprocessed body to `stripe.webhooks.constructEvent`. Don't let an upstream parser touch the body.
3. **Webhooks are out-of-order, at-least-once.** Reject rollbacks with `event.created`, and reconcile important values against Stripe as the source of truth. 2xx first, heavy processing async.
4. **The subscription is a state machine.** Fix `active`/`past_due`/`unpaid`/`canceled` in a table, separating `unpaid` = immediate revocation and `past_due` = dunning, and exhaust them with types.
5. **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](/case-studies/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](/blog/subscription-platform-billing-idempotency-type-safety), and the story of achieving payment idempotency on the AWS side is in the [DynamoDB article](/blog/dynamodb-payment-reliability-idempotency-zero-downtime).

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](/contact).
