[{"data":1,"prerenderedAt":1183},["ShallowReactive",2],{"article/stripe-webhook-idempotency-production":3},{"_path":4,"_draft":5,"_partial":5,"_locale":6,"title":7,"description":8,"featured":5,"author":9,"categories":10,"slug":11,"image":12,"imageAlt":22,"published":23,"draft":5,"createdAt":24,"updatedAt":25,"faqs":26,"body":42,"_type":1182,"isInteractive":5,"interactiveConfig":-1},"/articles/react/stripe-webhook-idempotency-production",false,"","Stripe Webhook Idempotency, the Production Pattern","Build a Stripe webhook handler that survives retries, replays, out-of-order events, and signature failures without double-granting a subscription.","Thomas Findlay","Javascript, Typescript, React","stripe-webhook-idempotency-production",[13,14,15,16,17,18,19,20,21],"/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-640w.avif","/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-1024w.avif","/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-1920w.avif","/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-640w.webp","/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-1024w.webp","/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-1920w.webp","/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-640w.png","/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-1024w.png","/images/articles/stripe-webhook-idempotency-production/stripe-webhook-idempotency-production-1920w.png","A Stripe webhook arrow hitting an events table gateway while a duplicate arrow bounces off a unique-key constraint.",true,"2026-07-18T00:00:00","2026-08-01T00:00:00",[27,30,33,36,39],{"question":28,"answer":29},"How do I make a Stripe webhook handler idempotent?","Put a UNIQUE constraint on the Stripe event id in your events table, insert the payload before running any business logic, and commit the row in the same transaction as the side effect. On a retry the insert fails with a duplicate-key error, you swallow that error, and you return 200. The handler becomes safe to replay any number of times.",{"question":31,"answer":32},"Why does Stripe send the same webhook twice?","Stripe retries any webhook delivery that does not return a 2xx response within the timeout window. If your handler is slow, crashes mid-way, or returns 5xx because of a transient downstream failure, Stripe queues the same event for redelivery. The same event id can arrive minutes or hours later, and the handler has to recognize and absorb the duplicate.",{"question":34,"answer":35},"Can Stripe webhooks arrive out of order?","Yes. Stripe does not guarantee ordering across webhook deliveries, and retries make late arrival routine. A `customer.subscription.updated` can arrive after a later `customer.subscription.deleted`. The production pattern is to never derive state from the event payload alone, and instead refetch the canonical object from the Stripe API inside the handler.",{"question":37,"answer":38},"Do I need an events table or can I use a Redis set?","A Redis set works as a quick dedup cache, but it is not enough on its own. The events table doubles as an audit log, lets you replay a failed handler against the original payload, and gives the dedup check transactional guarantees against your business writes. Use Postgres for correctness and Redis only as an additional fast-path filter.",{"question":40,"answer":41},"How do I verify the Stripe webhook signature in Node?","Use `stripe.webhooks.constructEvent(rawBody, signatureHeader, endpointSecret)` from the stripe Node SDK. The single mistake to avoid is passing the parsed JSON body instead of the raw request bytes, because the signature is computed over the exact bytes Stripe sent. Configure your framework to expose the raw body on the webhook route only.",{"type":43,"children":44,"toc":1160},"root",[45,62,69,98,104,120,125,134,146,159,166,178,183,189,234,247,253,265,271,276,284,295,362,368,380,388,397,446,452,473,502,510,519,556,562,576,582,646,654,663,705,711,724,732,741,774,780,785,813,824,836,842,863,878,887,892,905,914,926,939,948,974,980,985,1133,1139],{"type":46,"tag":47,"props":48,"children":49},"element","p",{},[50,53,60],{"type":51,"value":52},"text","Stripe webhook idempotency is the production pattern that turns a flaky payments integration into a handler you can leave alone. The shape is small: a uniqueness constraint on Stripe ",{"type":46,"tag":54,"props":55,"children":57},"code",{"className":56},[],[58],{"type":51,"value":59},"event.id",{"type":51,"value":61},", an events table written before any business logic, a transactional state change, and a 200 response only after the row is committed. Skip it, and one slow deploy double-grants a subscription.",{"type":46,"tag":63,"props":64,"children":66},"h2",{"id":65},"tldr-the-stripe-webhook-idempotency-rule",[67],{"type":51,"value":68},"TL;DR, the Stripe webhook idempotency rule",{"type":46,"tag":47,"props":70,"children":71},{},[72,74,80,82,88,90,96],{"type":51,"value":73},"A correct Stripe webhook handler does seven things, in this order, every time. Read the raw request body so the signature verifies against the exact bytes Stripe sent. Verify the signature with ",{"type":46,"tag":54,"props":75,"children":77},{"className":76},[],[78],{"type":51,"value":79},"stripe.webhooks.constructEvent",{"type":51,"value":81},". Insert the event into a table whose ",{"type":46,"tag":54,"props":83,"children":85},{"className":84},[],[86],{"type":51,"value":87},"stripe_event_id",{"type":51,"value":89}," column is ",{"type":46,"tag":54,"props":91,"children":93},{"className":92},[],[94],{"type":51,"value":95},"UNIQUE",{"type":51,"value":97}," before doing any business work. Let the duplicate-key error be the dedup signal on retries and replays. Derive state by refetching the current object from the Stripe API rather than trusting the payload, because events arrive out of order. Wrap the insert and the side effect in a single transaction (or use a two-phase commit pattern where you cannot). Return 200 only after the row is committed, and never before. Get those seven steps right and the handler becomes replay-safe by construction.",{"type":46,"tag":63,"props":99,"children":101},{"id":100},"why-the-tutorial-handler-eventually-double-grants",[102],{"type":51,"value":103},"Why the tutorial handler eventually double-grants",{"type":46,"tag":47,"props":105,"children":106},{},[107,109,118],{"type":51,"value":108},"The handler that ships in every Stripe tutorial verifies the signature, parses the event, runs the side effect, and returns 200. It works in a demo. A manual smoke test in staging also passes, because nobody on the QA team replays the same event twice in the same minute. Then the handler double-grants the first time production traffic hits it, because the demo never exercised the two cases that matter: a Stripe retry and an out-of-order delivery. Stripe documents both as expected behaviors under the ",{"type":46,"tag":110,"props":111,"children":115},"a",{"href":112,"rel":113},"https://docs.stripe.com/webhooks/best-practices",[114],"nofollow",[116],{"type":51,"value":117},"webhook best practices guide",{"type":51,"value":119},", and they are the reason the production pattern always includes an events table.",{"type":46,"tag":47,"props":121,"children":122},{},[123],{"type":51,"value":124},"Here is the version a new repo usually ships with.",{"type":46,"tag":47,"props":126,"children":127},{},[128],{"type":46,"tag":129,"props":130,"children":131},"strong",{},[132],{"type":51,"value":133},"server/api/stripe/webhook.ts (the tutorial handler)",{"type":46,"tag":135,"props":136,"children":141},"pre",{"className":137,"code":139,"language":140,"meta":6},[138],"language-ts","import express from 'express'\nimport Stripe from 'stripe'\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)\nconst endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!\n\nexport const webhookRouter = express.Router()\n\nwebhookRouter.post(\n  '/stripe/webhook',\n  express.raw({ type: 'application/json' }),\n  async (req, res) => {\n    const signature = req.headers['stripe-signature'] as string\n\n    let event: Stripe.Event\n    try {\n      event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret)\n    } catch (err) {\n      return res.status(400).send(`Webhook Error: ${(err as Error).message}`)\n    }\n\n    if (event.type === 'customer.subscription.created') {\n      await grantSubscription(event.data.object as Stripe.Subscription)\n    }\n\n    res.json({ received: true })\n  },\n)\n","ts",[142],{"type":46,"tag":54,"props":143,"children":144},{"__ignoreMap":6},[145],{"type":51,"value":139},{"type":46,"tag":47,"props":147,"children":148},{},[149,151,157],{"type":51,"value":150},"That handler is one network blip away from a double-grant. The signature check is correct, the body is raw, the secret is right. None of that protects you when Stripe retries the same event because your ",{"type":46,"tag":54,"props":152,"children":154},{"className":153},[],[155],{"type":51,"value":156},"grantSubscription",{"type":51,"value":158}," call took nine seconds to complete on a cold start and the platform returned a 5xx. Two retries deep, the user has been granted access three times, and the support ticket lands the next morning.",{"type":46,"tag":160,"props":161,"children":163},"h3",{"id":162},"stripe-retries-your-slow-handler-and-the-duplicate-event",[164],{"type":51,"value":165},"Stripe retries, your slow handler, and the duplicate event",{"type":46,"tag":47,"props":167,"children":168},{},[169,171,176],{"type":51,"value":170},"Stripe retries every webhook delivery that does not get a 2xx response within the timeout window, and it keeps retrying for up to three days on an exponential schedule. The handler does not have to crash for this to fire. A Lambda cold start that pushes the response past the platform's 10-second cap is enough. One transient 503 from a backend dependency is enough. Anything that causes the response to come back as 5xx, or not come back at all, queues the same event for redelivery with the same ",{"type":46,"tag":54,"props":172,"children":174},{"className":173},[],[175],{"type":51,"value":59},{"type":51,"value":177},".",{"type":46,"tag":47,"props":179,"children":180},{},[181],{"type":51,"value":182},"The naive handler treats each delivery as a fresh event. On the first call, the subscription is granted, the response is slow, Stripe times out and retries, the second call grants the subscription again. By the time you read the Slack message, the customer is paying once and consuming three seats. Retries are the headline failure, and they happen often enough that any handler without dedup will hit one within its first week of real traffic.",{"type":46,"tag":160,"props":184,"children":186},{"id":185},"out-of-order-events-from-parallel-webhooks",[187],{"type":51,"value":188},"Out-of-order events from parallel webhooks",{"type":46,"tag":47,"props":190,"children":191},{},[192,194,200,202,208,210,216,217,223,225,232],{"type":51,"value":193},"The second failure is quieter and harder to debug. Stripe does not guarantee the order in which webhook deliveries arrive, and that holds even when the underlying events happened in a clear sequence on Stripe's side. Three events can fire on the same subscription within seconds: ",{"type":46,"tag":54,"props":195,"children":197},{"className":196},[],[198],{"type":51,"value":199},"customer.subscription.created",{"type":51,"value":201},", then ",{"type":46,"tag":54,"props":203,"children":205},{"className":204},[],[206],{"type":51,"value":207},"customer.subscription.updated",{"type":51,"value":209}," flipping the status to ",{"type":46,"tag":54,"props":211,"children":213},{"className":212},[],[214],{"type":51,"value":215},"active",{"type":51,"value":201},{"type":46,"tag":54,"props":218,"children":220},{"className":219},[],[221],{"type":51,"value":222},"customer.subscription.deleted",{"type":51,"value":224},". Your endpoint can receive them in any order, and a retried delivery from earlier in the day can land in between two fresh ones. The ",{"type":46,"tag":110,"props":226,"children":229},{"href":227,"rel":228},"https://docs.stripe.com/api/events",[114],[230],{"type":51,"value":231},"Stripe events API",{"type":51,"value":233}," documents this directly.",{"type":46,"tag":47,"props":235,"children":236},{},[237,239,245],{"type":51,"value":238},"If your handler derives state from ",{"type":46,"tag":54,"props":240,"children":242},{"className":241},[],[243],{"type":51,"value":244},"event.data.object",{"type":51,"value":246},", the last event wins on your side, even when it was the first event on Stripe's side. Your database now shows the subscription as active, but the user canceled it ten minutes ago on Stripe. The production pattern is to never trust the payload as the source of truth, and instead refetch the current state inside the handler. Retries and out-of-order arrival force the handler to be replay-safe and order-independent.",{"type":46,"tag":63,"props":248,"children":250},{"id":249},"the-events-table-the-foundation-of-replayable-handlers",[251],{"type":51,"value":252},"The events table, the foundation of replayable handlers",{"type":46,"tag":47,"props":254,"children":255},{},[256,258,263],{"type":51,"value":257},"The events table is the smallest structural change that fixes both failures at once. A single Postgres table with a uniqueness constraint on the Stripe ",{"type":46,"tag":54,"props":259,"children":261},{"className":260},[],[262],{"type":51,"value":59},{"type":51,"value":264}," does three jobs at once. First, it is the dedup boundary, so any retried event is rejected by the database before the business logic runs. Second, it is the audit log, so every payment-affecting event lives next to a timestamp the on-call engineer can search. Third, it is the replay surface, so a failed handler can be re-run against the original payload without asking Stripe to redeliver. Once you have the table, the rest of the handler shape falls out of it.",{"type":46,"tag":160,"props":266,"children":268},{"id":267},"schema-with-a-unique-constraint-on-eventid",[269],{"type":51,"value":270},"Schema with a UNIQUE constraint on event.id",{"type":46,"tag":47,"props":272,"children":273},{},[274],{"type":51,"value":275},"The shape is small. Start with a Postgres migration that creates the table and the unique constraint in one step.",{"type":46,"tag":47,"props":277,"children":278},{},[279],{"type":46,"tag":129,"props":280,"children":281},{},[282],{"type":51,"value":283},"migrations/2026_05_27_create_stripe_events.sql",{"type":46,"tag":135,"props":285,"children":290},{"className":286,"code":288,"language":289,"meta":6},[287],"language-sql","create table stripe_events (\n  id uuid primary key default gen_random_uuid(),\n  stripe_event_id text not null unique,\n  event_type text not null,\n  payload jsonb not null,\n  received_at timestamptz not null default now(),\n  processed_at timestamptz,\n  process_error text\n);\n\ncreate index stripe_events_received_at_idx on stripe_events (received_at desc);\ncreate index stripe_events_processed_at_idx on stripe_events (processed_at)\n  where processed_at is null;\n","sql",[291],{"type":46,"tag":54,"props":292,"children":293},{"__ignoreMap":6},[294],{"type":51,"value":288},{"type":46,"tag":47,"props":296,"children":297},{},[298,300,305,307,312,314,320,322,328,330,336,338,344,346,352,354,360],{"type":51,"value":299},"Every column earns its place. ",{"type":46,"tag":54,"props":301,"children":303},{"className":302},[],[304],{"type":51,"value":87},{"type":51,"value":306}," is the dedup key, and the ",{"type":46,"tag":54,"props":308,"children":310},{"className":309},[],[311],{"type":51,"value":95},{"type":51,"value":313}," constraint is what enforces the rule at the database layer rather than relying on application code to remember. ",{"type":46,"tag":54,"props":315,"children":317},{"className":316},[],[318],{"type":51,"value":319},"event_type",{"type":51,"value":321}," makes the audit log searchable by category without parsing JSONB on every query. ",{"type":46,"tag":54,"props":323,"children":325},{"className":324},[],[326],{"type":51,"value":327},"payload",{"type":51,"value":329}," is the full event as Stripe sent it, which is the only way to replay a handler later without asking Stripe to redeliver. ",{"type":46,"tag":54,"props":331,"children":333},{"className":332},[],[334],{"type":51,"value":335},"received_at",{"type":51,"value":337}," and ",{"type":46,"tag":54,"props":339,"children":341},{"className":340},[],[342],{"type":51,"value":343},"processed_at",{"type":51,"value":345}," together let you distinguish \"we got the event but never finished it\" from \"we got the event and handled it cleanly\", and ",{"type":46,"tag":54,"props":347,"children":349},{"className":348},[],[350],{"type":51,"value":351},"process_error",{"type":51,"value":353}," keeps the failure reason on the same row instead of in a separate log. The partial index on ",{"type":46,"tag":54,"props":355,"children":357},{"className":356},[],[358],{"type":51,"value":359},"processed_at IS NULL",{"type":51,"value":361}," is the seam your replay job uses to find work that needs retry, and it stays tiny because most rows complete within seconds.",{"type":46,"tag":160,"props":363,"children":365},{"id":364},"insert-first-process-second",[366],{"type":51,"value":367},"Insert-first, process-second",{"type":46,"tag":47,"props":369,"children":370},{},[371,373,378],{"type":51,"value":372},"The handler inserts the row, then runs the business logic, then marks the row as processed. Order matters. Running the business logic first and the insert second means a retry reruns the side effect after a partial failure. The opposite order leaves the row sitting there with ",{"type":46,"tag":54,"props":374,"children":376},{"className":375},[],[377],{"type":51,"value":343},{"type":51,"value":379}," null on a partial failure, and a separate worker can pick it up later.",{"type":46,"tag":47,"props":381,"children":382},{},[383],{"type":46,"tag":129,"props":384,"children":385},{},[386],{"type":51,"value":387},"server/api/stripe/webhook.ts",{"type":46,"tag":135,"props":389,"children":392},{"className":390,"code":391,"language":140,"meta":6},[138],"import express from 'express'\nimport Stripe from 'stripe'\nimport { db } from '@/db'\nimport { handleEvent } from '@/server/api/stripe/handlers'\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)\nconst endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!\n\nexport const webhookRouter = express.Router()\n\nwebhookRouter.post(\n  '/stripe/webhook',\n  express.raw({ type: 'application/json' }),\n  async (req, res) => {\n    const signature = req.headers['stripe-signature'] as string\n\n    let event: Stripe.Event\n    try {\n      event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret)\n    } catch (err) {\n      return res.status(400).send(`Webhook Error: ${(err as Error).message}`)\n    }\n\n    try {\n      await db.transaction(async tx => {\n        const inserted = await tx.query(\n          `insert into stripe_events (stripe_event_id, event_type, payload)\n           values ($1, $2, $3)\n           on conflict (stripe_event_id) do nothing\n           returning id`,\n          [event.id, event.type, event],\n        )\n\n        if (inserted.rowCount === 0) {\n          return\n        }\n\n        const eventRowId = inserted.rows[0].id\n        await handleEvent(tx, event, eventRowId)\n\n        await tx.query(\n          `update stripe_events set processed_at = now() where id = $1`,\n          [eventRowId],\n        )\n      })\n    } catch (err) {\n      await db.query(\n        `update stripe_events\n         set process_error = $2\n         where stripe_event_id = $1`,\n        [event.id, (err as Error).message],\n      )\n      return res.status(500).json({ error: 'handler_failed' })\n    }\n\n    res.json({ received: true })\n  },\n)\n",[393],{"type":46,"tag":54,"props":394,"children":395},{"__ignoreMap":6},[396],{"type":51,"value":391},{"type":46,"tag":47,"props":398,"children":399},{},[400,402,408,410,415,417,423,425,430,432,437,439,444],{"type":51,"value":401},"Three behaviors come out of that shape. The ",{"type":46,"tag":54,"props":403,"children":405},{"className":404},[],[406],{"type":51,"value":407},"ON CONFLICT DO NOTHING",{"type":51,"value":409}," clause is the duplicate detector: the second time Stripe sends the same ",{"type":46,"tag":54,"props":411,"children":413},{"className":412},[],[414],{"type":51,"value":59},{"type":51,"value":416},", the insert is a no-op, ",{"type":46,"tag":54,"props":418,"children":420},{"className":419},[],[421],{"type":51,"value":422},"rowCount",{"type":51,"value":424}," is zero, the handler skips the side effect and returns 200. Wrapping the insert, the side effect, and the ",{"type":46,"tag":54,"props":426,"children":428},{"className":427},[],[429],{"type":51,"value":343},{"type":51,"value":431}," write in one transaction makes them atomic, so a crash midway leaves the row visible but with ",{"type":46,"tag":54,"props":433,"children":435},{"className":434},[],[436],{"type":51,"value":343},{"type":51,"value":438}," still null, ready for the replay worker. On the 500 path, writing the error onto the row before returning means the audit log carries the failure reason without needing a separate logging pipeline. The unique constraint on ",{"type":46,"tag":54,"props":440,"children":442},{"className":441},[],[443],{"type":51,"value":87},{"type":51,"value":445}," is the dedup boundary, and the row is the proof you processed the event.",{"type":46,"tag":63,"props":447,"children":449},{"id":448},"signature-verification-and-the-one-mistake-that-breaks-it",[450],{"type":51,"value":451},"Signature verification (and the one mistake that breaks it)",{"type":46,"tag":47,"props":453,"children":454},{},[455,457,462,464,471],{"type":51,"value":456},"Signature verification is the boundary that keeps a public webhook endpoint from being a free attack surface. Stripe signs every webhook delivery with the endpoint secret, and the Node SDK exposes ",{"type":46,"tag":54,"props":458,"children":460},{"className":459},[],[461],{"type":51,"value":79},{"type":51,"value":463}," to verify the signature and parse the payload in one step. Full mechanics live in the ",{"type":46,"tag":110,"props":465,"children":468},{"href":466,"rel":467},"https://docs.stripe.com/webhooks/signatures",[114],[469],{"type":51,"value":470},"webhook signatures docs",{"type":51,"value":472},". The one mistake almost every team makes is feeding the verifier the parsed JSON object instead of the raw request bytes.",{"type":46,"tag":47,"props":474,"children":475},{},[476,478,484,486,492,494,500],{"type":51,"value":477},"The wrong version looks like the right version. A route running after ",{"type":46,"tag":54,"props":479,"children":481},{"className":480},[],[482],{"type":51,"value":483},"express.json()",{"type":51,"value":485}," parses the body into an object, ",{"type":46,"tag":54,"props":487,"children":489},{"className":488},[],[490],{"type":51,"value":491},"constructEvent",{"type":51,"value":493}," receives that object after a ",{"type":46,"tag":54,"props":495,"children":497},{"className":496},[],[498],{"type":51,"value":499},"JSON.stringify",{"type":51,"value":501},", and the signature still verifies in development. It breaks the moment a property reorders between Stripe and your stringifier, or the moment a number loses a trailing decimal in the JSON round-trip, or the moment a future Stripe payload includes a Unicode character that your JSON encoder normalizes differently. The verifier needs the exact bytes.",{"type":46,"tag":47,"props":503,"children":504},{},[505],{"type":46,"tag":129,"props":506,"children":507},{},[508],{"type":51,"value":509},"server/api/stripe/webhook.ts (the signature step, isolated)",{"type":46,"tag":135,"props":511,"children":514},{"className":512,"code":513,"language":140,"meta":6},[138],"import express from 'express'\nimport Stripe from 'stripe'\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)\nconst endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!\n\nexport const webhookRouter = express.Router()\n\nwebhookRouter.post(\n  '/stripe/webhook',\n  express.raw({ type: 'application/json' }),\n  (req, res, next) => {\n    const signature = req.headers['stripe-signature']\n    if (typeof signature !== 'string') {\n      return res.status(400).send('Missing stripe-signature header')\n    }\n\n    try {\n      const event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret)\n      res.locals.event = event\n      next()\n    } catch (err) {\n      return res.status(400).send(`Webhook Error: ${(err as Error).message}`)\n    }\n  },\n)\n",[515],{"type":46,"tag":54,"props":516,"children":517},{"__ignoreMap":6},[518],{"type":51,"value":513},{"type":46,"tag":47,"props":520,"children":521},{},[522,524,530,532,538,540,546,548,554],{"type":51,"value":523},"The change is small and load-bearing. ",{"type":46,"tag":54,"props":525,"children":527},{"className":526},[],[528],{"type":51,"value":529},"express.raw({ type: 'application/json' })",{"type":51,"value":531}," is applied to this route only, which keeps ",{"type":46,"tag":54,"props":533,"children":535},{"className":534},[],[536],{"type":51,"value":537},"req.body",{"type":51,"value":539}," as a ",{"type":46,"tag":54,"props":541,"children":543},{"className":542},[],[544],{"type":51,"value":545},"Buffer",{"type":51,"value":547}," for the webhook and leaves every other route consuming JSON normally. Header access reads ",{"type":46,"tag":54,"props":549,"children":551},{"className":550},[],[552],{"type":51,"value":553},"stripe-signature",{"type":51,"value":555}," as a string (lowercase, because Node normalizes header names). The verifier receives the raw buffer, the case-sensitive header, and the endpoint secret, and returns the parsed event. If you are running behind a reverse proxy that re-encodes request bodies (some API gateways do), disable that behavior on the webhook path or the signature will never verify, no matter how correct the rest of the code is. The exact-bytes rule is the whole point.",{"type":46,"tag":63,"props":557,"children":559},{"id":558},"writing-the-handler-as-an-idempotent-state-machine",[560],{"type":51,"value":561},"Writing the handler as an idempotent state machine",{"type":46,"tag":47,"props":563,"children":564},{},[565,567,574],{"type":51,"value":566},"The handler stops being a series of conditional branches and starts being a state machine the moment you accept the events-table row as your source of work and the current Stripe object as your source of truth. An event is a notification that something changed. It is not a description of what it changed to. The ",{"type":46,"tag":110,"props":568,"children":571},{"href":569,"rel":570},"https://docs.stripe.com/webhooks/stripe-events",[114],[572],{"type":51,"value":573},"Stripe events documentation",{"type":51,"value":575}," makes this explicit, and it is the principle that makes out-of-order events stop mattering. Refetch the canonical object inside the handler, map its current state to your domain, and write the result. Older events become harmless because the refetch returns the same answer regardless of which event you process first.",{"type":46,"tag":160,"props":577,"children":579},{"id":578},"subscription-state-derived-from-the-current-stripe-object-not-the-event",[580],{"type":51,"value":581},"Subscription state derived from the current Stripe object, not the event",{"type":46,"tag":47,"props":583,"children":584},{},[585,587,592,594,600,602,607,609,614,616,622,624,629,631,636,638,644],{"type":51,"value":586},"Subscription handlers are where the principle pays off most. A ",{"type":46,"tag":54,"props":588,"children":590},{"className":589},[],[591],{"type":51,"value":207},{"type":51,"value":593}," event can carry a ",{"type":46,"tag":54,"props":595,"children":597},{"className":596},[],[598],{"type":51,"value":599},"status",{"type":51,"value":601}," of ",{"type":46,"tag":54,"props":603,"children":605},{"className":604},[],[606],{"type":51,"value":215},{"type":51,"value":608}," even when a later ",{"type":46,"tag":54,"props":610,"children":612},{"className":611},[],[613],{"type":51,"value":222},{"type":51,"value":615}," event has already landed and marked the subscription ",{"type":46,"tag":54,"props":617,"children":619},{"className":618},[],[620],{"type":51,"value":621},"canceled",{"type":51,"value":623},". Trusting the payload writes ",{"type":46,"tag":54,"props":625,"children":627},{"className":626},[],[628],{"type":51,"value":215},{"type":51,"value":630}," back over ",{"type":46,"tag":54,"props":632,"children":634},{"className":633},[],[635],{"type":51,"value":621},{"type":51,"value":637},", and the user keeps access they no longer pay for. The fix is to call ",{"type":46,"tag":54,"props":639,"children":641},{"className":640},[],[642],{"type":51,"value":643},"stripe.subscriptions.retrieve",{"type":51,"value":645}," inside the handler and use the returned object as the source of truth.",{"type":46,"tag":47,"props":647,"children":648},{},[649],{"type":46,"tag":129,"props":650,"children":651},{},[652],{"type":51,"value":653},"server/api/stripe/handlers/subscription.ts",{"type":46,"tag":135,"props":655,"children":658},{"className":656,"code":657,"language":140,"meta":6},[138],"import Stripe from 'stripe'\nimport type { PoolClient } from 'pg'\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)\n\nconst ACTIVE_STATUSES: ReadonlyArray\u003CStripe.Subscription.Status> = [\n  'active',\n  'trialing',\n  'past_due',\n]\n\nexport async function handleSubscriptionEvent(\n  tx: PoolClient,\n  event: Stripe.Event,\n): Promise\u003Cvoid> {\n  const subscriptionId = (event.data.object as Stripe.Subscription).id\n  const current = await stripe.subscriptions.retrieve(subscriptionId)\n\n  const planActive = ACTIVE_STATUSES.includes(current.status)\n  const customerId = typeof current.customer === 'string' ? current.customer : current.customer.id\n\n  await tx.query(\n    `update users\n     set plan_active = $2,\n         plan_status = $3,\n         plan_updated_at = now()\n     where stripe_customer_id = $1`,\n    [customerId, planActive, current.status],\n  )\n}\n",[659],{"type":46,"tag":54,"props":660,"children":661},{"__ignoreMap":6},[662],{"type":51,"value":657},{"type":46,"tag":47,"props":664,"children":665},{},[666,668,673,675,680,682,687,689,694,696,703],{"type":51,"value":667},"The behavior is order-independent by construction. Whether this handler runs in response to ",{"type":46,"tag":54,"props":669,"children":671},{"className":670},[],[672],{"type":51,"value":199},{"type":51,"value":674},", ",{"type":46,"tag":54,"props":676,"children":678},{"className":677},[],[679],{"type":51,"value":207},{"type":51,"value":681},", or ",{"type":46,"tag":54,"props":683,"children":685},{"className":684},[],[686],{"type":51,"value":222},{"type":51,"value":688},", it makes the same Stripe API call and writes the same canonical answer. A late retry of a ",{"type":46,"tag":54,"props":690,"children":692},{"className":691},[],[693],{"type":51,"value":207},{"type":51,"value":695}," from yesterday now writes yesterday's answer, which is what Stripe currently reports as the truth. The same approach is recommended in the ",{"type":46,"tag":110,"props":697,"children":700},{"href":698,"rel":699},"https://docs.stripe.com/billing/subscriptions/webhooks",[114],[701],{"type":51,"value":702},"billing subscriptions webhook guide",{"type":51,"value":704},". One refetch per event is the cost. A subscription state that never drifts from Stripe is the payoff.",{"type":46,"tag":160,"props":706,"children":708},{"id":707},"charge-events-granting-credit-safely",[709],{"type":51,"value":710},"Charge events, granting credit safely",{"type":46,"tag":47,"props":712,"children":713},{},[714,716,722],{"type":51,"value":715},"Charge events are where double-grants do real damage. A ",{"type":46,"tag":54,"props":717,"children":719},{"className":718},[],[720],{"type":51,"value":721},"payment_intent.succeeded",{"type":51,"value":723}," event triggers a credit grant, and a retry of the same event must not grant a second time. The events-table row id is the natural idempotency key. A credit-grants table that carries a foreign key to that row, with a uniqueness constraint on that foreign key, prevents two grants for the same event.",{"type":46,"tag":47,"props":725,"children":726},{},[727],{"type":46,"tag":129,"props":728,"children":729},{},[730],{"type":51,"value":731},"server/api/stripe/handlers/charge.ts",{"type":46,"tag":135,"props":733,"children":736},{"className":734,"code":735,"language":140,"meta":6},[138],"import Stripe from 'stripe'\nimport type { PoolClient } from 'pg'\n\nexport async function handlePaymentSucceeded(\n  tx: PoolClient,\n  event: Stripe.Event,\n  eventRowId: string,\n): Promise\u003Cvoid> {\n  const paymentIntent = event.data.object as Stripe.PaymentIntent\n  const customerId =\n    typeof paymentIntent.customer === 'string'\n      ? paymentIntent.customer\n      : paymentIntent.customer?.id\n\n  if (!customerId) return\n\n  await tx.query(\n    `insert into credit_grants (stripe_event_row_id, customer_id, amount_cents)\n     values ($1, $2, $3)\n     on conflict (stripe_event_row_id) do nothing`,\n    [eventRowId, customerId, paymentIntent.amount_received],\n  )\n}\n",[737],{"type":46,"tag":54,"props":738,"children":739},{"__ignoreMap":6},[740],{"type":51,"value":735},{"type":46,"tag":47,"props":742,"children":743},{},[744,746,751,753,758,760,765,766,772],{"type":51,"value":745},"Two safeguards stack. The events table's ",{"type":46,"tag":54,"props":747,"children":749},{"className":748},[],[750],{"type":51,"value":95},{"type":51,"value":752}," on ",{"type":46,"tag":54,"props":754,"children":756},{"className":755},[],[757],{"type":51,"value":87},{"type":51,"value":759}," blocks the retry from ever entering this handler, and the credit-grants table's ",{"type":46,"tag":54,"props":761,"children":763},{"className":762},[],[764],{"type":51,"value":95},{"type":51,"value":752},{"type":46,"tag":54,"props":767,"children":769},{"className":768},[],[770],{"type":51,"value":771},"stripe_event_row_id",{"type":51,"value":773}," is the second guard, in case a future code path calls this function more than once without going through the webhook entry. Because the grant is keyed on the events-table row, a single Stripe event grants at most one credit, even if a replay job re-enters the function or a developer manually triggers the handler from a debug console. An event triggers a refetch, the refetch is the source of truth, and the side effect is keyed on the event row id.",{"type":46,"tag":63,"props":775,"children":777},{"id":776},"replays-in-development-replays-in-production",[778],{"type":51,"value":779},"Replays in development, replays in production",{"type":46,"tag":47,"props":781,"children":782},{},[783],{"type":51,"value":784},"Replays are the developer-experience superpower of this design. The handler that survives a Stripe retry in production also survives a developer hitting the same endpoint with the same payload from their laptop. No special \"replay mode\" is needed, because the dedup logic and the refetch logic are the same code path either way. Two tools cover the two replay scenarios you will use.",{"type":46,"tag":47,"props":786,"children":787},{},[788,790,796,797,803,805,812],{"type":51,"value":789},"In development, the Stripe CLI forwards live test-mode events to your local handler and lets you replay specific events on demand. The ",{"type":46,"tag":54,"props":791,"children":793},{"className":792},[],[794],{"type":51,"value":795},"stripe listen",{"type":51,"value":337},{"type":46,"tag":54,"props":798,"children":800},{"className":799},[],[801],{"type":51,"value":802},"stripe trigger",{"type":51,"value":804}," commands are documented under the ",{"type":46,"tag":110,"props":806,"children":809},{"href":807,"rel":808},"https://docs.stripe.com/cli/listen",[114],[810],{"type":51,"value":811},"Stripe CLI listen guide",{"type":51,"value":177},{"type":46,"tag":135,"props":814,"children":819},{"className":815,"code":817,"language":818,"meta":6},[816],"language-bash","# Forward test-mode webhooks to your local handler\nstripe listen --forward-to localhost:3000/api/stripe/webhook\n\n# Trigger a synthetic event of any supported type\nstripe trigger customer.subscription.created\n\n# Resend a specific event you already received\nstripe events resend evt_1OXyAbCdEf12345\n","bash",[820],{"type":46,"tag":54,"props":821,"children":822},{"__ignoreMap":6},[823],{"type":51,"value":817},{"type":46,"tag":47,"props":825,"children":826},{},[827,829,834],{"type":51,"value":828},"In production, the Stripe dashboard exposes a \"Resend\" button on every event in the webhook log. Clicking it sends the same payload, with the same ",{"type":46,"tag":54,"props":830,"children":832},{"className":831},[],[833],{"type":51,"value":59},{"type":51,"value":835},", to your endpoint. The dedup check rejects the duplicate, the response is 200, and nothing in your database changes. Resending from the dashboard is the safe way to confirm a handler is replay-safe before a customer ever experiences a retry. The same code path serves both development replays and production redeliveries.",{"type":46,"tag":63,"props":837,"children":839},{"id":838},"tests-that-catch-what-manual-qa-cannot",[840],{"type":51,"value":841},"Tests that catch what manual QA cannot",{"type":46,"tag":47,"props":843,"children":844},{},[845,847,853,855,861],{"type":51,"value":846},"Manual QA does not catch idempotency bugs, because the failure mode requires two deliveries of the same event and a developer with one terminal cannot easily produce both at the same speed Stripe does. The three negative tests below catch every failure mode this article has named. Ship the handler with all three and the production gate is a green CI run, not a release-week prayer. The pattern is the same one I walk through in the ",{"type":46,"tag":110,"props":848,"children":850},{"href":849},"/blog/ai-code-review-checklist-react-vue",[851],{"type":51,"value":852},"AI code review checklist for React and Vue",{"type":51,"value":854}," and in ",{"type":46,"tag":110,"props":856,"children":858},{"href":857},"/blog/testing-ai-generated-code-react",[859],{"type":51,"value":860},"testing AI-generated code in React",{"type":51,"value":862},", and it applies to any boundary where retry semantics matter.",{"type":46,"tag":47,"props":864,"children":865},{},[866],{"type":46,"tag":129,"props":867,"children":868},{},[869,871,876],{"type":51,"value":870},"server/api/stripe/",{"type":46,"tag":129,"props":872,"children":873},{},[874],{"type":51,"value":875},"tests",{"type":51,"value":877},"/webhook-replay.test.ts",{"type":46,"tag":135,"props":879,"children":882},{"className":880,"code":881,"language":140,"meta":6},[138],"import request from 'supertest'\nimport { app } from '@/server/app'\nimport { db } from '@/db'\nimport { signEvent } from './helpers'\nimport { handleEvent } from '@/server/api/stripe/handlers'\n\nvi.mock('@/server/api/stripe/handlers', () => ({\n  handleEvent: vi.fn().mockResolvedValue(undefined),\n}))\n\ntest('replayed event is deduplicated, side effect runs once', async () => {\n  const event = {\n    id: 'evt_replay_1',\n    type: 'customer.subscription.created',\n    data: { object: { id: 'sub_1', customer: 'cus_1' } },\n  }\n  const { body, signature } = signEvent(event)\n\n  const first = await request(app)\n    .post('/api/stripe/webhook')\n    .set('stripe-signature', signature)\n    .set('content-type', 'application/json')\n    .send(body)\n\n  const second = await request(app)\n    .post('/api/stripe/webhook')\n    .set('stripe-signature', signature)\n    .set('content-type', 'application/json')\n    .send(body)\n\n  expect(first.status).toBe(200)\n  expect(second.status).toBe(200)\n\n  const { rowCount } = await db.query(\n    `select 1 from stripe_events where stripe_event_id = $1`,\n    ['evt_replay_1'],\n  )\n  expect(rowCount).toBe(1)\n  expect(vi.mocked(handleEvent)).toHaveBeenCalledTimes(1)\n})\n",[883],{"type":46,"tag":54,"props":884,"children":885},{"__ignoreMap":6},[886],{"type":51,"value":881},{"type":46,"tag":47,"props":888,"children":889},{},[890],{"type":51,"value":891},"The replay test is the one that catches the double-grant scenario from the opening of this article. Two posts, same body, same signature. One row, one side-effect call, two 200 responses. Without the unique constraint, the second insert silently succeeds and the side effect runs twice. With it, the second response is still a 200 because the duplicate is the expected case, not an error. That distinction matters: returning 4xx on a duplicate would tell Stripe the event was rejected and trigger more retries, which is the opposite of what you want.",{"type":46,"tag":47,"props":893,"children":894},{},[895],{"type":46,"tag":129,"props":896,"children":897},{},[898,899,903],{"type":51,"value":870},{"type":46,"tag":129,"props":900,"children":901},{},[902],{"type":51,"value":875},{"type":51,"value":904},"/webhook-signature.test.ts",{"type":46,"tag":135,"props":906,"children":909},{"className":907,"code":908,"language":140,"meta":6},[138],"import request from 'supertest'\nimport { app } from '@/server/app'\nimport { db } from '@/db'\nimport { signEvent } from './helpers'\n\ntest('tampered payload fails signature verification, no row written', async () => {\n  const event = {\n    id: 'evt_sig_1',\n    type: 'customer.subscription.created',\n    data: { object: { id: 'sub_2', customer: 'cus_2' } },\n  }\n  const { body, signature } = signEvent(event)\n  const tampered = body.replace('sub_2', 'sub_999')\n\n  const response = await request(app)\n    .post('/api/stripe/webhook')\n    .set('stripe-signature', signature)\n    .set('content-type', 'application/json')\n    .send(tampered)\n\n  expect(response.status).toBe(400)\n\n  const { rowCount } = await db.query(\n    `select 1 from stripe_events where stripe_event_id = $1`,\n    ['evt_sig_1'],\n  )\n  expect(rowCount).toBe(0)\n})\n",[910],{"type":46,"tag":54,"props":911,"children":912},{"__ignoreMap":6},[913],{"type":51,"value":908},{"type":46,"tag":47,"props":915,"children":916},{},[917,919,924],{"type":51,"value":918},"The signature test guards the boundary that keeps the endpoint from being a free attack surface. A tampered payload produces a 400 and writes nothing to the database, which means the events table stays clean of forged events and the alerting stack does not get noise from probes. Run this test once per CI build and a regression where someone accidentally re-enables ",{"type":46,"tag":54,"props":920,"children":922},{"className":921},[],[923],{"type":51,"value":483},{"type":51,"value":925}," on the webhook route fails immediately, instead of surfacing weeks later as \"webhooks suddenly stopped working\".",{"type":46,"tag":47,"props":927,"children":928},{},[929],{"type":46,"tag":129,"props":930,"children":931},{},[932,933,937],{"type":51,"value":870},{"type":46,"tag":129,"props":934,"children":935},{},[936],{"type":51,"value":875},{"type":51,"value":938},"/webhook-order.test.ts",{"type":46,"tag":135,"props":940,"children":943},{"className":941,"code":942,"language":140,"meta":6},[138],"import request from 'supertest'\nimport { app } from '@/server/app'\nimport { db } from '@/db'\nimport { signEvent } from './helpers'\nimport Stripe from 'stripe'\n\nvi.mock('stripe', () => {\n  const Stripe = vi.fn()\n  Stripe.prototype.subscriptions = {\n    retrieve: vi.fn().mockResolvedValue({\n      id: 'sub_3',\n      customer: 'cus_3',\n      status: 'canceled',\n    }),\n  }\n  Stripe.prototype.webhooks = {\n    constructEvent: (raw: Buffer) => JSON.parse(raw.toString()),\n  }\n  return { default: Stripe }\n})\n\ntest('older update event arrives after a delete, plan stays canceled', async () => {\n  await db.query(`insert into users (id, stripe_customer_id, plan_active, plan_status)\n                  values ('u1', 'cus_3', false, 'canceled')`)\n\n  const lateUpdate = {\n    id: 'evt_order_1',\n    type: 'customer.subscription.updated',\n    data: { object: { id: 'sub_3', status: 'active' } },\n  }\n  const { body, signature } = signEvent(lateUpdate)\n\n  const response = await request(app)\n    .post('/api/stripe/webhook')\n    .set('stripe-signature', signature)\n    .set('content-type', 'application/json')\n    .send(body)\n\n  expect(response.status).toBe(200)\n\n  const user = await db.query(\n    `select plan_active, plan_status from users where id = $1`,\n    ['u1'],\n  )\n  expect(user.rows[0].plan_active).toBe(false)\n  expect(user.rows[0].plan_status).toBe('canceled')\n})\n",[944],{"type":46,"tag":54,"props":945,"children":946},{"__ignoreMap":6},[947],{"type":51,"value":942},{"type":46,"tag":47,"props":949,"children":950},{},[951,953,958,960,965,967,972],{"type":51,"value":952},"The out-of-order test is the one that proves the refetch principle is wired correctly. Even though the event payload says ",{"type":46,"tag":54,"props":954,"children":956},{"className":955},[],[957],{"type":51,"value":215},{"type":51,"value":959},", the mocked Stripe API returns ",{"type":46,"tag":54,"props":961,"children":963},{"className":962},[],[964],{"type":51,"value":621},{"type":51,"value":966},", and the handler writes ",{"type":46,"tag":54,"props":968,"children":970},{"className":969},[],[971],{"type":51,"value":621},{"type":51,"value":973}," because it trusts the API over the payload. Remove the refetch and reintroduce direct payload reads, and this test fails immediately. That is the safety net: the test does not care whether the refetch is fast or slow or expensive; it cares that the canonical-source rule holds. Without these three tests, idempotency bugs ship by accident.",{"type":46,"tag":63,"props":975,"children":977},{"id":976},"a-reviewable-stripe-webhook-idempotency-checklist",[978],{"type":51,"value":979},"A reviewable Stripe webhook idempotency checklist",{"type":46,"tag":47,"props":981,"children":982},{},[983],{"type":51,"value":984},"The checklist below fits in a PR template and mirrors the seven steps from the TL;DR. If a webhook PR is missing any line, it is not ready to merge.",{"type":46,"tag":986,"props":987,"children":988},"ul",{},[989,1002,1014,1024,1048,1060,1072,1090,1095,1106,1111,1116],{"type":46,"tag":990,"props":991,"children":992},"li",{},[993,995,1000],{"type":51,"value":994},"Raw body parser on the webhook route only (",{"type":46,"tag":54,"props":996,"children":998},{"className":997},[],[999],{"type":51,"value":529},{"type":51,"value":1001},").",{"type":46,"tag":990,"props":1003,"children":1004},{},[1005,1007,1012],{"type":51,"value":1006},"Signature header read as a string from ",{"type":46,"tag":54,"props":1008,"children":1010},{"className":1009},[],[1011],{"type":51,"value":553},{"type":51,"value":1013},", never trusted from query or body.",{"type":46,"tag":990,"props":1015,"children":1016},{},[1017,1022],{"type":46,"tag":54,"props":1018,"children":1020},{"className":1019},[],[1021],{"type":51,"value":79},{"type":51,"value":1023}," called with the raw buffer, not a re-stringified object.",{"type":46,"tag":990,"props":1025,"children":1026},{},[1027,1033,1035,1040,1041,1046],{"type":46,"tag":54,"props":1028,"children":1030},{"className":1029},[],[1031],{"type":51,"value":1032},"stripe_events",{"type":51,"value":1034}," table with ",{"type":46,"tag":54,"props":1036,"children":1038},{"className":1037},[],[1039],{"type":51,"value":95},{"type":51,"value":752},{"type":46,"tag":54,"props":1042,"children":1044},{"className":1043},[],[1045],{"type":51,"value":87},{"type":51,"value":1047}," migrated and indexed.",{"type":46,"tag":990,"props":1049,"children":1050},{},[1051,1053,1058],{"type":51,"value":1052},"Insert into ",{"type":46,"tag":54,"props":1054,"children":1056},{"className":1055},[],[1057],{"type":51,"value":1032},{"type":51,"value":1059}," runs before any business logic, inside a transaction with the side effect.",{"type":46,"tag":990,"props":1061,"children":1062},{},[1063,1065,1070],{"type":51,"value":1064},"Side effect keyed on the events-table row id, not on the Stripe ",{"type":46,"tag":54,"props":1066,"children":1068},{"className":1067},[],[1069],{"type":51,"value":59},{"type":51,"value":1071}," directly.",{"type":46,"tag":990,"props":1073,"children":1074},{},[1075,1077,1082,1084,1089],{"type":51,"value":1076},"Subscription and customer state derived from ",{"type":46,"tag":54,"props":1078,"children":1080},{"className":1079},[],[1081],{"type":51,"value":643},{"type":51,"value":1083}," (or equivalent), not from ",{"type":46,"tag":54,"props":1085,"children":1087},{"className":1086},[],[1088],{"type":51,"value":244},{"type":51,"value":177},{"type":46,"tag":990,"props":1091,"children":1092},{},[1093],{"type":51,"value":1094},"200 response returned only after the row is committed; 5xx on handler failure so Stripe retries.",{"type":46,"tag":990,"props":1096,"children":1097},{},[1098,1100,1105],{"type":51,"value":1099},"Replay test asserts one row and one side effect for two deliveries with the same ",{"type":46,"tag":54,"props":1101,"children":1103},{"className":1102},[],[1104],{"type":51,"value":59},{"type":51,"value":177},{"type":46,"tag":990,"props":1107,"children":1108},{},[1109],{"type":51,"value":1110},"Signature-mismatch test asserts 400 and zero rows written.",{"type":46,"tag":990,"props":1112,"children":1113},{},[1114],{"type":51,"value":1115},"Out-of-order test asserts canonical state wins over a stale payload.",{"type":46,"tag":990,"props":1117,"children":1118},{},[1119,1124,1126,1131],{"type":46,"tag":54,"props":1120,"children":1122},{"className":1121},[],[1123],{"type":51,"value":343},{"type":51,"value":1125}," watchdog or replay worker runs against rows where ",{"type":46,"tag":54,"props":1127,"children":1129},{"className":1128},[],[1130],{"type":51,"value":359},{"type":51,"value":1132}," for more than N minutes.",{"type":46,"tag":63,"props":1134,"children":1136},{"id":1135},"closing-thought",[1137],{"type":51,"value":1138},"Closing thought",{"type":46,"tag":47,"props":1140,"children":1141},{},[1142,1144,1150,1152,1158],{"type":51,"value":1143},"The pattern is small but it is structural. Three load-bearing decisions carry it: the unique constraint, the insert-first ordering, and the refetch rule. Get those right and webhook reliability stops being a feature to build and becomes a property of the schema. The negative tests are the gate that keeps it that way. This pattern protects you from the failures Stripe causes by design (retries, redeliveries, out-of-order arrival); it does not protect you from a logic bug in the side effect itself. For the surrounding observability and error-handling work, ",{"type":46,"tag":110,"props":1145,"children":1147},{"href":1146},"/blog/hardening-ai-generated-react-app-for-production",[1148],{"type":51,"value":1149},"hardening an AI-generated React app for production",{"type":51,"value":1151}," covers the wider production checklist, and ",{"type":46,"tag":110,"props":1153,"children":1155},{"href":1154},"/blog/vibe-coding-vs-production-coding-react",[1156],{"type":51,"value":1157},"vibe coding vs production coding in React",{"type":51,"value":1159}," is the broader argument about which corners do and do not bend under real traffic.",{"title":6,"searchDepth":1161,"depth":1161,"links":1162},2,[1163,1164,1169,1173,1174,1178,1179,1180,1181],{"id":65,"depth":1161,"text":68},{"id":100,"depth":1161,"text":103,"children":1165},[1166,1168],{"id":162,"depth":1167,"text":165},3,{"id":185,"depth":1167,"text":188},{"id":249,"depth":1161,"text":252,"children":1170},[1171,1172],{"id":267,"depth":1167,"text":270},{"id":364,"depth":1167,"text":367},{"id":448,"depth":1161,"text":451},{"id":558,"depth":1161,"text":561,"children":1175},[1176,1177],{"id":578,"depth":1167,"text":581},{"id":707,"depth":1167,"text":710},{"id":776,"depth":1161,"text":779},{"id":838,"depth":1161,"text":841},{"id":976,"depth":1161,"text":979},{"id":1135,"depth":1161,"text":1138},"markdown",1785621731007]