Idempotency keys on the write API
A network failure does not say whether the write happened. The request may have reached the server, committed, and lost only its response. Repeating it is how a caller finds out — and without a key, repeating it is how a second invoice gets created and a second document number gets burnt.
An Idempotency-Key request header makes the repeat safe: the first request’s
outcome is remembered under that key, and a later request carrying it replays that
outcome instead of running again.
The policy
Section titled “The policy”Idempotency-Key is optional. A request without one behaves exactly as it always
has, and is not protected.
- The key is caller-chosen, 1–255 printable ASCII characters. Anything longer or
outside that range is rejected with
400 core.request_rejected. - A key is scoped to the workspace. Two workspaces using the same string never collide.
- A key is remembered for 24 hours from the moment the write commits.
- A key is minted fresh per call, so two deliberate calls remain two writes. Reuse one only to repeat a call that may already have happened.
What a repeat gets back
Section titled “What a repeat gets back”The second request answers with the response body the first request produced —
byte for byte, including the document number it drew. The status is the route’s
own, so a replayed create still answers 201.
The body is a snapshot of that first answer, not a fresh read. A record edited between the two requests still replays as it was, which is what a caller retrying a lost response asked for.
A request that arrives while the first one with its key is still running gets
409 core.request_in_flight. The outcome is not settled yet, so this is the one
error worth retrying after a short pause.
A write that fails releases its key. The next request with that key is a real attempt, not a replay of a failure.
Which routes honour it
Section titled “Which routes honour it”| Route | Honours the key |
|---|---|
POST/PATCH/DELETE on /api/business-object/... |
Yes |
POST .../archive, POST .../restore |
Yes |
POST /api/business-object/.../actions/:action |
Yes |
POST /api/actions/:namespace/:name |
Yes |
Every route under /api/drafts |
Yes |
POST /api/functions/:namespace/:name |
No — a function is a read over POST |
| File upload, roles, role assignments | No |
@onerp/client sends a key on every write it makes, so the browser app, the
warehouse app, Node-RED flows and any integration built on the client are covered
without doing anything.
Where it is stored
Section titled “Where it is stored”In Redis, under the workspace’s key space, with a 24-hour expiry. The reservation is taken before the handler runs and confirmed after the unit of work commits, which is what lets two concurrent duplicates be told apart from one.
A stored answer is the size of the record it carries: a sales order serializes to about 900 bytes at one line and 2 KB at five, growing roughly 310 bytes per line. Ten thousand writes a day hold about 20 MB for the 24 hours they live.
Redis cannot join the SQL transaction, so the guarantee is not absolute: a process that dies between the commit and the confirm leaves the key unwritten, and a repeat would write again. The window is milliseconds.
What this does not cover
Section titled “What this does not cover”An idempotency key deduplicates a repeat of one request. It does not make an operation idempotent in itself: two calls with two keys are two writes, which is what partial invoicing and partial fulfilment need them to be.
It also does not reach anything that never goes over HTTP. A workflow, a job processor or the seeder calls the manager in process, with no request to carry a header, so a replayed event or a re-run job writes again.