Idempotency
Networks fail mid-request. The API lets you retry mutations without double-writing: send an x-idempotency-key header, and a repeated call returns the original result instead of running again.
How to retry safely
- Generate a stable key per logical action
Any string that uniquely names the action — a UUID, or a deterministic name like
invoice-send-inv_812-attempt1. The same logical action must reuse the same key; a new action needs a new key. - Send it on the mutation
curl -X POST https://os.cohortapp.com/api/v1/books.invoiceSend \ -H "Authorization: Bearer $COHORT_API_KEY" \ -H "Content-Type: application/json" \ -H "x-idempotency-key: invoice-send-inv_812" \ -d '{"params": {"id": "inv_812"}}' - Retry with the same key on timeout or network error
If the first attempt committed, the retry returns the stored result verbatim with HTTP 200 — nothing runs twice. If it never committed, the retry executes normally.
What the server does
Replay is scoped to methods the protocol marks idempotent (flagged in the reference) — mostly creates and state transitions where a duplicate would be destructive. For those methods:
- The cached result is keyed by org + actor + method + key. A key from one agent or workspace can never replay another's result.
- The result is stored in a dedicated idempotency table in the same transaction as the write and its ledger event, so the cache and the audit chain commit atomically.
- A replay hits before any handler logic runs and returns HTTP
200with the original result. The ledger gains no second event.
Two deliberate boundaries:
- Results never enter the ledger. The audit chain holds the redacted event payload only; the handler's return value lives solely in the side table. Side-effecting methods that are not idempotent — notably
credential.lease, whose result is a leased secret — are never cached at all. - Non-idempotent methods run every time. Verbs where each call is a distinct act (
books.lineRecode,books.statementRun,books.taxReturn,crm.createDeal, and others) ignore the header's replay semantics; sending a key on them is harmless but does not dedupe.
Layered dedup in messaging
messaging.send additionally requires a caller-supplied idempotencyId inside its params. The server dedupes on (channel, idempotencyId) at the business level: a retried send returns the existing message and never double-posts, even if you forgot the header. Use both — the header for transport retries, the param because the schema requires it.
After a governance hold, use a fresh key
When a call returns a held result (status: "approval_required" — see Governance), that outcome is the cached result for your key. After the human approves, retry the action with the approval proof and a fresh x-idempotency-key — replaying the old key would just return the hold frame again.
Books transitions no-op when replayed against an already-transitioned row (an
approved bill stays approved), so even a lost idempotency key does not
double-move money. The explicitly non-idempotent Books verbs — lineRecode,
invoicePromise, statementRun, taxReturn, stpResubmit — treat each call
as a new act by design.