Reference

Idempotency

Send one Idempotency-Key so a retried mutating call runs once and a later retry replays the stored response.

A checkout that times out should not charge the card twice. Send one Idempotency-Key for that attempt. The server runs do once and replays the stored response when the same key comes back.

The one rule

Mint one key per logical call and send it on every retry of that call. Two submits are two calls unless your app passes the same key. Provider webhooks do not send this header — keep a unique constraint on the provider's event id.

Quick start

Mark the flow

idempotency: "required" rejects a call that omits the header. Leave it off and the header is optional. "required" on a read, GET, stream, or live flow fails extract.

import { on, flow, http } from "okengine";

export const charge = on(
  http.post("/charges", { in: ChargeIn, out: ChargeOut }),
  flow("payments.charge", {
    idempotency: "required",
    do: async (input, fx) => {
      return fx.store(db).insert(charges).values(input);
    },
  }),
);

Call it

The client sends Idempotency-Key on every non-GET call. Pass a string to reuse a key across your own retries, or false to send none.

await api.payments.charge({ amount: 1000 });
await api.payments.charge({ amount: 1000 }, { idempotencyKey: "checkout-attempt-9f3a2c" });

Read the replay

The first response is the live result. A later call with the same key, principal, and payload returns that stored response and sets Idempotent-Replayed: true. The client copies that onto meta.idempotentReplayed.

When the header is honored

All of these are required. Otherwise the header is ignored.

CheckHonored when
EntryHTTP, and the method is not GET. RPC counts. fx.call has no request, so it does not.
EffectsInferred effects include a write, emit, send, call, fetch, ask, or embed — or the flow uses fx.raw. Secrets alone do not qualify.
ShapeNot stream, and not a live SSE feed.
OptionOmitted or { ttl } is auto (header optional). "required" or { required: true } demands the header. false ignores it.

QUERY is not GET. It qualifies when the rest of the row matches.

OptionMeaning
omittedauto when the flow qualifies. Default TTL 24h.
"required"Missing header is 400 IdempotencyKeyMissing.
{ ttl: "1h" }auto, with that TTL. ms, s, m, h, or d.
{ required: true, ttl: "1h" }Required, with that TTL.
falseHeader ignored.

An unparseable TTL, or required on a flow that cannot honor the header, fails extract.

The key is 16–255 printable ASCII characters (0x200x7E). Anything else is 400 IdempotencyKeyInvalid.

The scope is tenant, principal, and flow. The principal is user:<id>, else apikey:<id>, else anon. The same key from two people is two records. The fingerprint is a hash of the flow name and the validated input. The same key with a different payload is 422 IdempotencyKeyReused.

What a second request sees

The claim happens after the gate and after input validation. A 401, 403, or validation failure stores nothing, so the same key can still succeed.

RowResponse
No row, or the TTL has passeddo runs. The response is stored until the TTL.
Same payload, completedStored status, body, content-type, and location, plus Idempotent-Replayed: true.
Same payload, still running409 IdempotencyInProgress and Retry-After (seconds until the lease, at least 1).
Different payload422 IdempotencyKeyReused.

A stream, an SSE body, or a raw Response that is not a buffered JSON envelope is not stored. The live response is returned and the row is deleted.

A throw before any non-read effect deletes the row. The same key may run again. A throw after a write, emit, send, call, fetch, ask, embed, or fx.raw stores the 500 InternalError envelope. A new key is required. A declared fx.fail(...) is stored as that failure, not as a 500.

Crashes

The in-progress lease is 30 seconds and is renewed while do runs. A disconnect does not cancel a claimed do. The lease is what notices a crash.

FlowAfter the lease expires
Not durableThe next request with the same key runs do again. That is at-least-once.
DurableThe next request resumes that journal run. Completed steps are not run again.

A durable sleep stores the 204 and parks the journal run. A retry replays the 204. The scheduler continues the run.

Where the body lives

Rows sit in oke_idempotency on the journal driver: memory in tests, .oke/idempotency.json beside the journal file, and Postgres when that journal driver is bound.

The stored body can contain personal data and stays until the TTL. Expired rows are deleted on the next claim and by a periodic sweep.

The Console flow contract shows an idempotency pill when the mode is auto or required.

Troubleshooting

Learn more

  • CallingidempotencyKey and the retry rule
  • Errors — the four idempotency codes and their statuses
  • The Architecture — a retry of one call runs once

Next

On this page