Skip to main content
Payments & billing
Stripe
TypeScript
B2B SaaS
アーキテクチャ設計
Next.js
決済

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
Last updated
Updated
Reading time
26 min read
Author
友田 陽大
Share
Contents

"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 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). 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. 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.

InvariantWhat threatens itThe means to uphold itThis article's chapter
Don't execute the same operation twiceNetwork resend, Webhook double deliveryIdempotency-Key + your own event.id de-duplication§1・§3
Don't believe a fake requestA third party's forged Webhook, a tampered amountSignature verification (raw body) + decide the amount on the server§2・§7
Don't roll back stateOut-of-order event arrivalA 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 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). 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.

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

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: 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 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, count is 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:

  1. Where the change can be written as a monotonic state transition, write it that way. For invoice.paid, "advance to paid only if not already paid or void" gives the same final state in any arrival order.
  2. Where it isn't monotonic (a subscription's status moves back and forth between active and past_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.

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

statusMeaning (official)The product's access rightWhat to do
trialingDuring the trial period. Usable even before payment. Goes to active on the first payment.GrantOpen full features
activeNormal. The required payments are complete.GrantOpen full features
incompleteThe first payment is needed within 23 hours (including 3DS-etc. auth waits).HoldDon't open yet. Wait for completion
incomplete_expiredThe first payment wasn't completed within 23 hours and expired. No charge.Not allowedCreating a new subscription is needed
past_dueThe latest invoice's payment failed/wasn't attempted. Invoices keep being generated. Retries continuing.Restrict/notifyDunning. Try to recover with Smart Retries
unpaidThe latest invoice is unpaid. Retries are exhausted.Immediate revocationStop access
canceledCanceled. Terminal, irreversible.RevokeTidy up entitlements
pausedWhen there's no payment method at trial end and pause is set. No invoice generation.HoldWait 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).

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

ViewpointStripe Checkout (hosted)Direct Payment Intents (Elements, etc.)
Implementation costLow (just redirect)High (UI, state, errors yourself)
PCI compliance burdenMinimal (SAQ A)Large
3DS / various payment methodsStripe auto-handlesHandle yourself
UI freedomMedium (about theming)High
Suited toMost SaaS billing / subscriptionsWhen 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/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, 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 numberThe behavior it simulates
4242 4242 4242 4242Success (Visa)
4000 0000 0000 9995Declined for insufficient funds
4000 0025 0000 31553DS authentication needed (requires authentication)
4000 0000 0000 0341Card 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).

// ❌ 致命的:クライアントの金額を信じる
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_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).
  • 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. Stripe advises against deciding order with event.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_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 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.

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

Frequently asked questions

Why does Stripe Webhook signature verification fail?
The cause in most cases is passing already-parsed JSON to constructEvent. Because the signature is computed against the 'raw request body (raw body),' you must secure the raw body before the framework's body parser and pass it as-is to verification.
Is it abnormal for the same Webhook event to arrive multiple times?
No, it's normal. Webhooks are 'at-least-once' delivery, presupposing out-of-order and duplicates. Put a unique constraint on event.id on the receiving side and commit that record in the same transaction as the business update. If you commit the record separately first, an event whose processing failed is treated as a duplicate on retry and never processed.
What should I use for the Idempotency-Key?
Not a random value each time, but a value deterministically derived from the operation (e.g. a key tied to an order ID or event ID). Becoming the same key on retry lets Stripe exclude duplicate POSTs and prevent double charging.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Payment and subscription infrastructure (Stripe / serverless), from design to production

Correctness in payments comes from the structure of the code, not from operational vigilance. Idempotency keys and conditional writes absorb retries, balance updates ride in one atomic transaction, and webhooks are deduplicated by a unique event-id constraint with ordering guarantees. I designed and led that reliability layer on a serverless payments platform: zero double charges and zero balance inconsistencies in production, and a 13-phase schema migration completed without a second of downtime.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading