Idempotency and retry
There are two levels of identity, and they do not mix:
- Logical operation:
idempotency_key+external_event_id, in the body. They stay the same on every retry and are never reused for another operation, not even after the data expires. - Send attempt:
X-FTID-Request-Id,X-FTID-Timestampand the signature. They change on every attempt.
What the server does with the operation
Section titled “What the server does with the operation”| Situation | Response |
|---|---|
| New key | 201, with the newly stored assessment |
| Same key, same content, result still within its approved history window | 200, with exactly the assessment already stored, without recomputing — even if the original occurred_at is already outside the ±300 s window |
| Same key, same content, result outside its approved history window | 410 assessment-result-expired, with no historical result or assessment identifier; no recomputation or new usage |
Same idempotency_key or same external_event_id with different content |
409 idempotency-conflict, with no partial effect |
Person, account or session references incompatible with bindings already stored, for example an account_ref first sent with another subject_ref |
409 binding-conflict, with nothing stored; not retryable |
“Content” means the ten body fields other than deadline_ms. Changing only deadline_ms keeps
the same operation. Changing any other field creates a conflict — including replacing null
with a token, changing the token or rewriting occurred_at with a different spelling (Z and
+00:00 are different spellings).
The event_context_token is single-use and is consumed together with the assessment. A
retry of the same operation does not consume it again; a new operation with an already used
token receives 400 event-context-invalid.
{ "type": "https://fingertrackid.dev/problems/idempotency-conflict", "title": "Chave de idempotência já usada com outro conteúdo", "status": 409, "correlation_id": "corr-problem-idempotency-0001", "retryable": false}When to retry
Section titled “When to retry”| Result | Retry? | How |
|---|---|---|
201 or 200 |
No | Use the assessment |
401 timestamp-out-of-window |
Yes | Fix the clock; new attempt |
401 request-replayed |
Yes | New attempt — and fix the code that reused the request_id |
429 rate-limited |
Yes | Wait for Retry-After (today 1 s; at least 1 s if it is missing); new attempt |
503 assessment-unavailable |
Yes | Wait for Retry-After (today 1 s; at least 1 s if it is missing); new attempt. The state may be indeterminate |
410 assessment-result-expired |
No | The old result is no longer available. Stop reconciliation retries; preserve the original operation IDs and do not regenerate the operation |
| Network error, timeout, truncated response or response above 65536 bytes | Yes | Treat as indeterminate; new attempt with the same body |
Any other 4xx, or 500 |
No | Fix the cause (see errors) |
A new attempt is always: same body, new request_id, current timestamp and new
signature. This applies to 429 and 503 too: the request_id of the answered attempt may
already have been consumed. Retry a bounded number of times, never in a loop.
Attempt 1 same body bytes X-FTID-Request-Id: <new UUIDv7> X-FTID-Timestamp: <now> X-FTID-Signature: <new> → 503 assessment-unavailable, Retry-After: 1
wait at least Retry-After
Attempt 2 the same body bytes X-FTID-Request-Id: <another new UUIDv7> X-FTID-Timestamp: <now> X-FTID-Signature: <new> → 200 with the assessment already stored, or 201 if the first attempt stored nothingTimeout without a response
Section titled “Timeout without a response”After a timeout, the assessment may have been stored without the response reaching you. A retry
with the same body resolves it: a retained result returns 200; an expired result returns
non-retryable 410. Use a bounded retry
policy — backoff, Retry-After and the deadline of your business operation. Once that deadline
is exhausted, apply your own fallback and reconcile later, with the same body.
A body that carries an event_context_token reconciles this way only if the first attempt was
stored, or while the token is still before its expiresAt (at most your application’s
evidence window, 60 or 300 seconds, after its
collection). Otherwise the same body returns 400 event-context-invalid; assess the event as a
new operation with null instead
(integration guidance).
There is no recovery GET: recovery is the same POST.