Skip to content
Skip to main content
Novel Systems home
Decision log
D-074August 5, 2026

An idempotency key is a promise that the body will not move, so the body must not read the clock

Affects: backend/src/integrations/stripe/checkout.ts, backend/test/adapters.test.ts (seven new assertions), app/api/billing/checkout-session/route.ts, app/api/cpq/calculate/route.ts.

What went wrong

The billing runner shipped in D-073 was reachable, described accurately, and broken. Pressing the button returned upstream_unavailable. The backend was returning 500, and the 500 was Stripe returning 400:

Keys for idempotent requests can only be used with the same parameters they were first used with.

The Idempotency-Key was novel-checkout-{quote id}-{hour bucket}, deliberately bucketed by the hour so a retry inside the retry window de-duplicates while a genuine re-request the next day mints a fresh page. That reasoning was sound and is preserved. The defect was one line away from it: the session body set expires_at from Date.now(). So the key stood still for an hour while the body moved every second, and Stripe — correctly — refused every call after the first one in each hour.

The bug was invisible in every test because each test built one body and looked at it. Nothing built two and compared them.

The decision, part one: stop the body reading the clock

Every field of the request body must be a pure function of (quote, returnTo, bucket). The clock is read exactly once per session request, in currentBucket(), and that single value feeds both the key and the body.

expires_at is therefore anchored to the start of its bucket rather than to the moment of the call. That costs a little precision — a caller arriving at the far end of an hour gets 22 hours of validity rather than 23 — and the cost is worth paying, because Stripe's window is 30 minutes to 24 hours and 22 hours sits comfortably inside it. SESSION_TTL_SECONDS moved from 24 hours to 23 to keep the far end of the bucket under the ceiling.

The decision, part two: make the key derive from the body it is sent with

Part one was written first, shipped, and was not enough — which is worth recording, because the reasoning that stopped short is the more instructive half.

The alternative considered and rejected at the time was hashing the body into the key. The objection was that it would destroy de-duplication: a body containing a timestamp hashes differently every second, so no two calls would ever share a key and every retry would mint a second payment page. That objection was correct about the code as it stood — and part one is precisely what stopped being true. Once expires_at is bucket-anchored, the body is constant for the whole hour, so its hash is too.

So the key is now novel-checkout-{quote id}-{bucket}-{16 hex of SHA-256 over the posted fields}, and checkoutIdempotencyKey takes the built form as an argument rather than rebuilding it, so the bytes hashed are the bytes sent.

This matters for a failure part one could not prevent. Pinning the body to the bucket keeps it still for an hour; it does nothing about the body changing because somebody edits this file. A price change, an added line, a different TTL — each would arrive at Stripe under a key already registered against the old shape, and be refused with the same opaque 400, in production, on deploy, for up to an hour per affected quote. Hashing the body means a changed body is automatically a changed key. The property is now structural rather than a convention a comment asks the next reader to honour.

How this is prevented from drifting apart again

Six assertions, and the ones that matter are not the ones that look important.

Two calls with the same bucket must be deepEqual — that is the part-one regression test, and it fails the moment anyone reintroduces a clock read anywhere in the body. expires_at is asserted to be exactly `bucket * 3600 + 23

  • 3600` and to advance by exactly one hour between adjacent buckets, and to sit

inside Stripe's 30-minute floor and 24-hour ceiling.

For part two, the key must *change* when a single field is edited and must *not* change when nothing is — sensitivity and stability asserted separately, because a fingerprint that failed either way would be silently useless in opposite directions. And the end-to-end test now reads the key off the recorded request header and checks it equals the key computed from that same request's recorded body, which is the property Stripe is actually checking.

The second defect this exposed

Tracing this took a hosting console, because the frontend proxy returned the same upstream_unavailable code from two different situations: the upstream answered with a failure, and the upstream never answered. Those are different facts with different fixes and they were indistinguishable to a public reader and to the person debugging it.

They are now upstream_refused — an answer arrived and it was a refusal, and the upstream's own status number is carried alongside — and upstream_unreachable, which means a timeout or a dead socket and carries nothing, because the exception's message can name internal hosts. The identical pair in app/api/cpq/calculate/route.ts was split the same way in the same change, since a diagnostic that only exists on the route where it was needed once is not a diagnostic.

It paid for itself inside the hour. The first test after deploying part one came back upstream_refused carrying upstreamStatus: 500, and those two facts together are what established that the frontend had deployed and the backend was still the problem — which under the old single code would have taken another trip to a hosting console, and which is what led to part two.

What would make this wrong

If Stripe ever needed a genuinely per-call field in the session body — a nonce it required to differ — both halves break at once: the body could no longer be pinned to the bucket, and its fingerprint would change on every call. Idempotency would then have to move to our side: a row recording the session id created for a quote, consulted before calling out. That is a migration and a table, not a correction to this entry.