Idempotency
The Idempotency-Key header, what a replay returns, and why reusing a key with a different body is a 409: safe retries for money movement.
Networks lie. A request times out, your client retries, and now you can't tell whether the first attempt moved money or vanished into the ether. Retrying blindly risks a double transfer; not retrying risks a stuck payment. Idempotency is how you retry money POSTs safely: fire the same request as many times as you like and it takes effect exactly once.
Every money-moving POST requires an Idempotency-Key header. You generate
it (a UUID is ideal), send it with the request, and reuse the same key when
you retry that same logical operation.
curl -X POST https://sandbox.api.acute.network/v1/wallets/acuinf…wlt/transfer \
-H "Authorization: Bearer acuinf_test_…" \
-H "Idempotency-Key: 6f1c2e7a-9b04-4f8e-bc31-3a2d5e7f9012" \
-H "Content-Type: application/json" \
-d '{ "destinationWalletId": "acuinf…wlt", "amount": 50000 }'The six endpoints that require a key:
| Operation | Endpoint |
|---|---|
| Transfer | POST /wallets/:id/transfer |
| Withdraw | POST /wallets/:id/withdraw |
| Pay (settle a virtual-wallet payment) | POST /wallets/:id/pay |
| Create payment | POST /payments |
| Refund a payment | POST /payments/:id/refund |
| Create payout batch | POST /payouts |
Reads and GETs don't need a key
Idempotency is for state-changing money moves. GETs are already safe to
retry, and creating a wallet or submitting KYC is naturally idempotent on its
own inputs: those don't take a key.
Omit the key on one of the six and you get a 400:
{
"success": false,
"statusCode": 400,
"error": {
"type": "validation_error",
"code": "IDEMPOTENCY_KEY_MISSING",
"message": "An Idempotency-Key header is required for this operation.",
"details": { "fields": [] }
},
"meta": { "requestId": "req_4f9a1c2b8e7d6a3f0b5c9e21" }
}When Acute sees a key it has processed before, it does not run the operation
again. It returns the original result (the same data, the same status code),
so your retry observes exactly what the first attempt produced.
The one thing that changes: meta.requestId. The envelope's request id (and
the X-Request-Id header) always identifies the actual HTTP exchange you just
made, even on a replay. So a replay is a faithful copy of the original data
with a fresh correlation id, Stripe-style.
1st POST (key 6f1c…) → 201 data: { id: "acuinf…trf", status: "completed" } meta.requestId: req_aaa…
2nd POST (key 6f1c…) → 201 data: { id: "acuinf…trf", status: "completed" } meta.requestId: req_bbb…
▲ same key, same body ▲ identical data ▲ fresh idOne key = one operation
A key identifies a single logical operation, not a session. Generate a new key for each new transfer/payout you intend; reuse a key only to retry that exact one. Persist the key alongside the operation in your own DB so a process restart retries with the same key.
A key is bound to the request that first used it. If you send the same key
with a different body, that's almost always a bug: you've accidentally reused
a key for a new operation. Acute refuses it with 409:
{
"success": false,
"statusCode": 409,
"error": {
"type": "conflict_error",
"code": "IDEMPOTENCY_KEY_REUSED",
"message": "This Idempotency-Key was already used with a different request.",
"details": {}
},
"meta": { "requestId": "req_7d2e9a1b4c6f8e0d3a5b9c11" }
}This is a guardrail, not an obstacle: it catches the case where you'd have created a second, different money movement under a key you thought was safe.
If a retry arrives while the original is still running (you fired twice in
quick succession, or a timeout raced the response), the second one gets a
distinct 409 telling you the first is still in flight:
{
"success": false,
"statusCode": 409,
"error": {
"type": "conflict_error",
"code": "IDEMPOTENCY_IN_PROGRESS",
"message": "A request with this Idempotency-Key is already in progress.",
"details": {}
},
"meta": { "requestId": "req_2b8c4e6a0d1f3a5b7c9e1d33" }
}Back off briefly and retry the same key: once the first finishes, the next
attempt replays its cached result. Branch on the code
(IDEMPOTENCY_IN_PROGRESS) to distinguish this transient case from
IDEMPOTENCY_KEY_REUSED, which is a real conflict you should fix in code.
Generate one key per operation
A crypto.randomUUID() per logical money move. Persist it with the
operation.
Send it, with the unchanged body, on every attempt
Same key, same body: that's what makes a retry a replay instead of a new transfer.
On 5xx or a network error, retry the same key
With backoff. The replay returns the original outcome the moment it's known.
On IDEMPOTENCY_IN_PROGRESS, back off and retry
The original is still running; the next attempt will replay its result.
Never mutate the body between retries
Change the amount, the destination, or even reorder JSON keys, and you've made
it a different request, which a reused key will reject with
IDEMPOTENCY_KEY_REUSED. If the operation genuinely changed, it's a new
operation: new key.