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.

a transfer with an idempotency key
bash
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:

OperationEndpoint
TransferPOST /wallets/:id/transfer
WithdrawPOST /wallets/:id/withdraw
Pay (settle a virtual-wallet payment)POST /wallets/:id/pay
Create paymentPOST /payments
Refund a paymentPOST /payments/:id/refund
Create payout batchPOST /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:

400 Bad Request, missing key
json
{
  "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.

first call vs replay
text
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 id

One 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:

409 Conflict, key reused for a different request
json
{
  "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:

409 Conflict, original still processing
json
{
  "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.

1

Generate one key per operation

A crypto.randomUUID() per logical money move. Persist it with the operation.

2

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.

3

On 5xx or a network error, retry the same key

With backoff. The replay returns the original outcome the moment it's known.

4

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.