Skip to content

Idempotency

Send Idempotency-Key on any write you might need to retry — a timeout, a dropped connection, a 5xx — without risking a duplicate.

Idempotency-Key: <your-key>

A blank header (absent, empty string, or whitespace-only) is treated as no key supplied wherever the header is optional: the request is processed normally and no idempotency record is created.

Keys must not exceed the endpoint's length limit — see the table below. A key over that limit is rejected with 400 invalid_idempotency_key before any processing happens, on every endpoint in the table. Keys must also not contain control characters — also invalid_idempotency_key, on every endpoint in the table (bills/invoices/credit notes, payroll, and migration); there is no other character-set restriction, and keys are otherwise stored and compared as opaque strings.

Which endpoints require it

  • Payroll (POST /api/v1/orgs/{org_id}/pay-runs, and POST /api/v1/orgs/{org_id}/pay-runs/{pay_run_id}/correct) requires Idempotency-Key. A missing or blank header is rejected with idempotency_key_required (400) before any processing happens.
  • Migration API (POST batch-submit) requires Idempotency-Key, and it must equal the request's batch_id exactly — a mismatch is idempotency_key_mismatch (400). See Migration API.
  • Bills, invoices and credit notes (the receiver endpoints under /api/v1/orgs/{org_id}/bills, /invoices, /credit-notes) honour the header but do not require it: a keyless push still gets deduplicated at the business level (org-wide bill/invoice number uniqueness; credit notes by an application-level (org, contact, reference) check), just not by this mechanism.

Other endpoints (reads, webhook management, connections) do not use Idempotency-Key — it only applies to writes that create a ledger-facing record.

The groups below implement idempotency differently — read the row for the endpoint you're calling, not just the general rules above.

Bills / invoices / credit notes Payroll — create pay run Payroll — correct pay run Migration
Key required? No (see above) Yes Yes Yes, and must equal batch_id
Scope (application, org, key) (application, org, key) (application, org, key) — the reversal side additionally derives a second, internal key from it (org, batch_id) — not scoped to the application
Max key length 255 characters 128 characters 128 characters 64 characters (batch_id)
Body compared on replay? Yes — a SHA-256 hash of the raw request body Yes — a SHA-256 hash of the raw request body Partially — the replacement leg compares against a stored hash the same way, but that only fires when the key names a different pay run than the one being corrected. Reused against the original's own key, the outcome is always 409 correction_replacement_not_posted regardless of body (see below) — there is no cached-replay path on this endpoint to compare a body against. Only package_sha256 is compared, not the full body
Same key, same body, retried Returns the original stored response (status + body) Returns 200 with the original pay run (not the original 201) If the earlier call already completed, the original pay run is no longer in a correctable state — fails 409 pay_run_not_postable, not a cached replay. There is no idempotent replay on this endpoint once a correction has landed; look the result up instead of retrying. Returns 200 with the batch's current status/summary, not a snapshot from submission
Same key, different body/target 409 idempotency_conflict 409 idempotency_conflict Reusing the key against a different original pay run id, or against an unrelated pay run's key with a different body, is refused: 409 idempotency_conflict. Reusing the original's own key is 409 correction_replacement_not_posted instead, whatever the body. 409 package_mismatch when package_sha256 differs
Retention 48 hours, then purged by an hourly cron No expiry — the key lives as long as the PayRun row No expiry — the key lives as long as the PayRun row No expiry — the key lives as long as the MigrationBatch row

A key longer than the limit above is rejected with 400 invalid_idempotency_key (the platform envelope on bills/invoices/credit notes and payroll; the migration API's own {code, message} shape) before any database work happens — no record is written for a rejected key.

The migration API's 64-character limit is narrower than its batch_id column itself (which allows 128): every batch's Idempotency-Key/batch_id is also written into the audit trail as request_id, a narrower, harder- to-widen column, so 64 is the real binding limit end to end. In practice this is not a tight fit — the reference client's make_batch_id produces ~30-character ids.

What happens while the first request is in flight

Bills, invoices, credit notes, and migration. Idempotency-key acquisition and the rest of request processing run inside one database transaction, and the losing side of a concurrent duplicate-key write is handled explicitly. If a second request arrives with the same key while the first is still uncommitted, its attempt to acquire the same key blocks on the database's unique index rather than proceeding independently:

  • If the first request's transaction commits, the second unblocks and sees the now-stored response — it returns the cached result (bills/ invoices/credit notes) or the batch's current summary (migration) rather than reprocessing.
  • If the first request's transaction rolls back (an unhandled error, for example), the key was never actually claimed, and the second request proceeds as if it were the first — it processes normally and stores its own response.

Payroll — create pay run. A concurrent duplicate-key write is handled explicitly: if two requests with the same key both miss the initial lookup and race to create the PayRun, the losing request's insert trips the same-key uniqueness constraint, and that request's body is compared against the winner's stored hash the same as any other replay — a matching body returns 200 with the winner's original pay run (the same outcome as an ordinary sequential replay); a different body returns 409 idempotency_conflict instead. A different key reused against the same period still surfaces as 409 duplicate_posted_period (a separate uniqueness constraint on (application, org, pay_period_end), unrelated to the key); the same key reused against a different period is not treated as a new post for that period at all — the initial key lookup finds the original PayRun regardless of period and returns it with 200 (see the table above). Which of the two outcomes you get is distinguished by which database constraint the insert actually violated. Even so, don't send concurrent requests with the same key against payroll; retry sequentially, and if you're unsure whether an earlier attempt landed, look the pay run up rather than resending.

Payroll — correct pay run. This endpoint does not have the same explicit handling for a concurrent duplicate-key write — depending on exactly which database constraint the two requests race against, the outcome is not guaranteed to be the cached-response behaviour described elsewhere on this page. Don't send concurrent requests with the same key against this endpoint; retry sequentially, and if you're unsure whether an earlier attempt landed, look the pay run up rather than resending.

See also

  • Payroll — the pay-run receiver's required-key behaviour.
  • Migration API — the batch-submit endpoint's Idempotency-Key == batch_id contract.