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

  1. 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.

  2. 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"}}'
    
  3. 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 200 with 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 writes are also idempotent by state

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.