Idempotency
Every write carries an Idempotency-Key header. Send the same key twice and the second call returns
the first call's response instead of doing the work again.
curl -sS -X POST https://inbox.example.com/v1/posts \
-H 'x-api-key: sk_live_…' \
-H 'x-axis-tenant: ws_01J8Z…' \
-H 'idempotency-key: 4d1f2c9a-6b0e-4a1d-9c3f-0f2a7b5e8d10' \
-H 'content-type: application/json' \
-d '{"caption":"Launch day","targets":[…]}'
Miss the header on a write and you get idempotency_key_required (400) before the handler runs.
The requirement is driven by the HTTP verb
This is the part that surprises people. resolveRoutePolicy computes dataWrite like this:
dataWrite: meta?.dataWrite ?? isWriteMethod(req.method)
@DataWrite() sets it explicitly, but in its absence the verb decides. Every POST, PUT, PATCH and
DELETE is a data write by default, whether or not it mutates anything. Several POST routes that only
read therefore demand a key:
| Route | What it actually does |
|---|---|
POST /v1/templates/verify-media-url |
Probes a media URL |
POST /v1/email-domains/register |
Registers a sending domain |
POST /v1/email-domains/:id/verify |
Checks DNS |
POST /v1/email-domains/:id/refresh |
Re-reads DNS records |
POST /v1/email-validation/single |
Validates one address |
POST /v1/email-validation/bulk |
Validates a list |
POST /v1/flows/:id/simulate-intent |
Dry-runs intent matching |
POST /v1/flows/:id/simulate-ai-handoff |
Dry-runs an AI handoff |
POST /v1/realtime/tokens |
Mints a realtime token |
Nothing there is unsafe to repeat, but the pipeline does not know that, and defaulting the other way
would mean a forgotten @DataWrite() silently disables replay protection on a real mutation. The
default is safe-by-omission; the cost is a few keys you did not expect to need.
Three routes opt out with @ReadOnlyPost() — a query whose spec is too big for a query string:
POST /v1/campaigns/preflightPOST /v1/reports/queryPOST /v1/reports/export
How a key is matched
The reservation is scoped to (appId, tenantId, key). Two tenants can use the same key string without
colliding.
The request is fingerprinted as:
sha256(method + "\n" + url + "\n" + rawBody)
The separators keep the fields unambiguous. Note that url includes the query string, and rawBody
is the bytes as sent — so a re-serialised body with different key ordering is a different
fingerprint.
Reservations live for 24 hours.
Four outcomes
| Outcome | What happens |
|---|---|
acquired |
First time for this key. The handler runs; on success the status and body are persisted. |
replay |
Same key, same fingerprint, already completed. The cached body is returned with the original status code — a replayed create still answers 201, not 200. |
conflict |
Same key, different fingerprint → idempotency_conflict (409). |
in_progress |
Same key, the first request has not finished → idempotency_in_progress (409). |
On replay the interceptor sets the response status from the stored record before returning the
stored body. That is why the reserved status is derived from @HttpCode metadata (falling back to 201
for POST, 200 otherwise) rather than read off the response object — at interceptor time res.statusCode
is still 200 and would be wrong.
Handler errors release the reservation
catchError((err) =>
from(this.store.release(scope)).pipe(concatMap(() => throwError(() => err))),
)
If your handler throws, the reservation is released and the original error propagates. A retry with
the same key then proceeds normally rather than getting stuck on in_progress for 24 hours. That is
the right trade: a failed write left nothing behind, so repeating it is safe.
Gotcha: operator writes are not protected
if (!req.authContext || !req.tenantContext) return next.handle();
The interceptor bails when there is no tenant context. @Operator() routes are cross-tenant by design
and TenantGuard deliberately skips resolution for them, so tenantContext is never set — and every
@OperatorWrite() route falls straight through the interceptor.
Retrying an operator write re-executes it. Approving a connection request, entering provider credentials, running a test: none of these are replay-protected. Send a key if you like; nothing reads it. If your operator UI retries on timeout, make the underlying action tolerant of being run twice, or do not retry it.
The reason is structural, not an oversight: the reservation scope is (appId, tenantId, key) and an
operator route genuinely has no tenant to scope against.
What the SDK does for you
@~inbox/sdk generates a UUID key for every write when you do not pass one:
const idempotencyKey = options.idempotencyKey ?? (isWrite ? uuid() : undefined);
Crucially the key is computed once, outside the retry loop, so the SDK's own retries on 429 and
5xx reuse it. A retried write therefore replays rather than duplicating. Pass your own
idempotencyKey when the key needs to survive beyond one client call — a job that may itself be
re-run, a user-initiated action you want to make repeat-safe across a page reload.
await inbox.posts.create(input, { idempotencyKey: `post:${jobId}` });