Skip to content
Chapter 63Lesson 6

Quiz - Webhook ingestion

Quiz progress

0 / 0

Your Stripe webhook verifies fine against stripe trigger events locally, but in production every real event returns 400. The handler reads the body like this:

const event = await request.json();
const ok = await verify(JSON.stringify(event), signature, secret);

What’s the bug?

JSON.parse then JSON.stringify rebuilds a JSON string, not the bytes Stripe signed — whitespace, key order, and number formatting drift, so the HMAC over the re-stringified body never matches v1. Read the body once with request.text() and verify those exact bytes.

The production webhook secret is wrong; the local CLI secret happens to match. Verifying against JSON.stringify(event) is fine once the secret is correct.

request.json() is too slow and Stripe times out before the 400 is sent. Switch to request.text() to read the body faster.

Two retries of the same checkout.session.completed land on two workers at once. The handler does SELECT from processed_events, and if no row exists, does the work and INSERTs the receipt. Why does this double-process, and what’s the structural fix?

Both workers’ SELECTs run before either INSERTs, so both see “not seen” and both proceed — a TOCTOU race. Collapse check and claim into one INSERT ... ON CONFLICT DO NOTHING RETURNING; a returned row means you won, zero rows means stand down.

The SELECT runs on a stale read replica. Point the dedup read at the primary and the second worker will see the first worker’s row.

Raise the isolation level to serializable so the second worker’s SELECT blocks until the first transaction commits.

The handler can’t do everything a checkout.session.completed implies — send the welcome email, recompute analytics, call a CRM — synchronously before returning 2xx. What’s the durable rule about timing?

Return 2xx quickly — on the order of a couple of seconds — doing only the minimal DB mutation that must be atomic with the claim, and hand every side effect (email, analytics, API calls) to a background job that runs after the response.

You have a hard 30-second contractual budget; as long as the whole handler — emails and API calls included — finishes under 30 seconds, doing it all inline is fine.

Do all the work inline but wrap the slow parts in Promise.race with a timeout, returning 200 if they don’t finish in time.

Two customer.subscription.updated events for one org arrive reordered: past_due (created at T) lands first, then active (created at T-5s, an older event) lands second. How do you stop the stale active from clobbering newer state?

Put the ordering check inside the UPDATE’s WHERE: update only where last_event_at is null or last_event_at < event.created, and set last_event_at = event.created in the same statement. The stale event matches zero rows and is a silent no-op.

In application code, SELECT the row’s last_event_at, compare it to event.created, and skip the UPDATE if the incoming event is older.

Reject any event whose event.created is older than now() minus a few seconds, since a stale event will have an older timestamp than the current clock.

The Checkout redirect lands the user on /success before the checkout.session.completed webhook has updated their entitlement, so the page shows “Free plan” to someone who just paid. What’s the senior fix?

Keep the success page a reader: render a “finalizing…” state and have a Client Component poll via router.refresh() (with a time budget) until the webhook lands and the read returns “paid.”

Have the success page confirm the session and write the entitlement itself, so the row is correct the moment the page renders and the webhook becomes a no-op.

Cache the entitlement read on the success page and call router.refresh() to bust the cache once the webhook fires.

A teammate “adds idempotency” to a public POST route by generating the dedup key inside the handler: const key = crypto.randomUUID() on each request, then INSERT ... ON CONFLICT DO NOTHING. Why does this protect nothing?

A retry re-enters the handler and generates a different UUID, so the two attempts never share a key and the unique constraint has nothing to catch on. The key must be minted by the source that owns “this attempt” and re-sent on the retry.

crypto.randomUUID() isn’t collision-free enough for an idempotency key; switch to a UUIDv7 so retries hash to the same value.

ON CONFLICT DO NOTHING swallows the conflict silently; you need to catch the unique-violation error instead for the dedup to register.

A user whose address bounced once (their inbox was full) is now suppressed and locked out. They request a password reset. What does the system do, and where does the decision live?

The reset call passes bypassSuppression: true at the call site, so sendEmail sends despite the suppression row. The webhook still records the bounce as a fact; the send side decides when a fact can be overridden.

The webhook handler skips writing the suppression row for addresses tied to accounts that might need recovery, so the reset isn’t blocked.

The reset is blocked and the user must contact support to be un-suppressed before any account-recovery email can reach them.

Quiz complete

Score by topic