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:

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}` });