Idempotency
Stripe's semantics on an Idempotency-Key header, scoped per tenant so a rotation cannot double-charge.
There is nothing to configure in the dashboard. A caller opts in by sending Idempotency-Key on a mutating request, up to 255 characters, and the SDK does the rest:
- The status and body of the first request are stored whether it succeeded or not, so a replay of a request that
500'd returns the same500. - A replay carries
idempotent-replayed: true. - The same key with a different request is
422 idempotency_key_reuse. - A duplicate arriving while the first is still running is
409withRetry-After, not a block. PATCHandPUTare covered too. "PUT is idempotent by definition" is true of the resource and false of the email it sends.- Records live 24 hours by default and 7 days at most (
ttlMs).
The fingerprint covers the method, path, sorted query and canonicalised body, never the headers, so a retry from a different client library with a different User-Agent is the same request.
Scope
Records are scoped per project, environment and tenant, never per key. Per-key scoping breaks rotation with overlap: the old key writes the record, the retry arrives on the new key, and your customer is charged twice by the feature meant to prevent it. The scope hash is computed in your process, so the caller's key never reaches Keyring's logs.
An unverified decision, one admitted fail-open with no policy record, is refused idempotency, because without a tenant there is no scope to store the record under.
Mounting it
The fingerprint needs a parsed body, so on Express it is a second mount after your body parser. Fastify and NestJS need no second step; the framework pages show each.
app.use(mw);
app.use(express.json());
app.use(keyringIdempotency(mw.keyring));Where the guarantee ends
If your process dies after your handler committed but before the record was written, the retry re-executes. This is at-least-once behaviour and a property of every middleware-shaped idempotency layer. The mitigation is the standard one: make your own write idempotent on a unique constraint derived from the request. A process that dies while the response is streaming is fine, because the record is written before the body is flushed. A slow handler is fine too: the 30-second lease is refreshed while it runs.
Responses over 256 KB are not stored. The record is kept, so conflict detection still works, and a replay answers 409 response_too_large_to_replay.
When the store is unreachable
idempotency.mode is closed by default, the opposite of the rate limiter's. A limit not enforced during an outage admits excess traffic; a record not written executes a payment twice, and the request in front of us explicitly asked for exactly-once by carrying the header. Refusing it with 503 is declining to promise what cannot be delivered, to a caller holding a retry loop. mode: 'open' serves it anyway for handlers that are already idempotent on their own side; idempotency: false removes the feature.
A 5xx your transient predicate accepts, by default one carrying Retry-After or a 503, releases the record instead of storing it, so the retry executes: "my database was failing over" is not "this operation was attempted".