# Idempotency

The routes that move funds, and the bank ramp endpoints that create something, take an `Idempotency-Key` header. Everywhere else, know what repeating a call does before you retry it.

## Overview

Without a key, endpoints don't detect duplicate requests: a repeated `POST` is processed again. These make a retry safe:

- the optional `Idempotency-Key` header of the **money routes** — sends, broadcasts, swaps, approvals, the dApp broadcast, and an operation's speed-up and drop (below);
- the same header on the **bank ramp** endpoints that create a KYC session, a virtual IBAN, a payout, a payout quote or a payout paid from the user's wallet (below), on the card onramp's [Open a Session](https://docs.aureahub.com/docs/card-onramp-create.md) and [Open a Purchase](https://docs.aureahub.com/docs/card-wallets-session-create.md), and on [Earn](https://docs.aureahub.com/docs/guide-earn.md)'s deposits, withdrawals and broadcasts, as their pages describe;
- the `idempotencyKey` body field of [agent payment executions](https://docs.aureahub.com/docs/agentic-api.md).

## Idempotency-Key on Money Routes

These routes accept an `Idempotency-Key` header of up to 255 letters, digits, `-` or `_`. It is optional — a request without it behaves exactly as it always has — except for a tenant on the institutional security profile, which must send it: without it the call answers `400` `IDEMPOTENCY_KEY_REQUIRED` and nothing runs.

| Route | Page |
| --- | --- |
| POST /v1/transactions/ | [Create Transaction](https://docs.aureahub.com/docs/tx-create.md) |
| POST /v1/transactions/{id}/broadcast | [Broadcast Transaction](https://docs.aureahub.com/docs/tx-broadcast.md) |
| POST /v1/swap/approve | [Approve Token](https://docs.aureahub.com/docs/swap-approve.md) |
| POST /v1/swap/execute | [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md) |
| POST /v1/swap/execute-solana | [Execute Solana Swap](https://docs.aureahub.com/docs/swap-execute-solana.md) |
| POST /v1/swap/execute-gasless | [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md) |
| POST /v1/swap/broadcast | [Broadcast Swap Tx](https://docs.aureahub.com/docs/swap-broadcast.md) |
| POST /v1/swap/broadcast-approve | [Broadcast Approve Tx](https://docs.aureahub.com/docs/swap-broadcast-approve.md) |
| POST /v1/swap/broadcast-gasless | [Broadcast Gasless Tx](https://docs.aureahub.com/docs/swap-broadcast-gasless.md) |
| POST /v1/dapps/broadcast | [Broadcast DApp Transaction](https://docs.aureahub.com/docs/dapps-broadcast.md) |
| POST /v1/operations/{id}/speed-up | [Speed Up (switched off until Aurea turns it on)](https://docs.aureahub.com/docs/operation-speed-up.md) |
| POST /v1/operations/{id}/drop | [Drop (switched off until Aurea turns it on)](https://docs.aureahub.com/docs/operation-drop.md) |

The routes that move funds in the products Aurea switches on per tenant — the savings supply, withdrawal and broadcast, and the euro IBAN payout order — take the header with the same rules. [Contact us for details](https://www.aureahub.com/contact).

| Request | Answer |
| --- | --- |
| **No header** | Runs as it always has. The answer carries no `idempotency-replayed` header. For an institutional tenant: `400` `IDEMPOTENCY_KEY_REQUIRED`, nothing runs. |
| **A new key** | Runs. Its answer is kept for 24 hours and carries `idempotency-replayed: false`. |
| **The same key and the same request, after an answer** | The first answer again — same status, same body — with `idempotency-replayed: true`. Nothing is signed, sent or broadcast a second time. |
| **The same key and a different request** | `409` `IDEMPOTENCY_KEY_REUSED`. Nothing runs. |
| **The same key while the first request is still running** | `409` `IDEMPOTENCY_IN_PROGRESS`. Wait a moment and send it again. |
| **The same key after a first request that may have signed or sent and never answered** | `409` `IDEMPOTENCY_OUTCOME_UNKNOWN`. Aurea will not run it again, even after 24 hours: check whether the operation happened, then use a new key. |
| **The same key and the same request, after a refusal made before anything was signed or sent** | Runs again: a validation error, a signature from another key or an unknown id frees the key. |
| **The same key and the same request, after a `202`** | Institutional tenants: the call's operation was held for approval ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md)). The same `202` again, with `idempotency-replayed: true` — never a second operation. It does not change once the operation is approved: follow the operation. |
| **A key with other characters, or longer than 255** | `400` `IDEMPOTENCY_KEY_INVALID`. Nothing runs. |
| **A key sent with a token that belongs to no tenant** | `400` `IDEMPOTENCY_KEY_INVALID`. Nothing runs. |

- **A key belongs to your tenant and the route**, not to the user who sends it: two of your users sending the same key to the same route share it. Make every key unique — a UUID — and never derive it from the request. The same key on another route is another key.
- **The same request** means the same path parameters, query and body; two bodies with the same fields and values, in any order, are the same.
- **What is kept.** An answer is kept; an error is not. A request refused before anything was signed or sent frees its key. A request that fails after it signed or sent — a node's refusal of a broadcast, for example — leaves its key unknown, and so does a `5xx` that came after that point: the client gets the same `5xx` as without a key, and the key then answers `409` `IDEMPOTENCY_OUTCOME_UNKNOWN`. The dApp broadcast answers its own refusals (`400` `UNKNOWN_CHAIN` and `NO_RPC_URL`, the node's `422`): those are its answer, and are replayed.
- **24 hours.** After 24 hours a key that was answered or freed is forgotten: the same key runs again. A key whose outcome is unknown is kept until Aurea settles it.

The refusals have the usual error envelope, with the code in `details.code`:

```json
{
  "statusCode": 409,
  "error": "ConflictError",
  "message": "This Idempotency-Key was used for a different request.",
  "details": { "code": "IDEMPOTENCY_KEY_REUSED" }
}
```

| Status | details.code | message |
| --- | --- | --- |
| 400 | IDEMPOTENCY_KEY_INVALID | `An Idempotency-Key is up to 255 letters, digits, - or _.` |
| 400 | IDEMPOTENCY_KEY_INVALID | `An Idempotency-Key belongs to a tenant: this caller has none.` |
| 400 | IDEMPOTENCY_KEY_REQUIRED | `This request needs an Idempotency-Key header.` |
| 409 | IDEMPOTENCY_KEY_REUSED | `This Idempotency-Key was used for a different request.` |
| 409 | IDEMPOTENCY_IN_PROGRESS | `The first request with this Idempotency-Key is still running. Try again in a moment.` |
| 409 | IDEMPOTENCY_OUTCOME_UNKNOWN | `The first request with this Idempotency-Key may have taken effect, and its outcome is not known yet. It will not be run again: check the operation before retrying with a new key.` |

```javascript
// One key per send: create it once, keep it with the pending send, reuse it on every retry
const idempotencyKey = crypto.randomUUID();

async function send(token, request) {
  const res = await fetch('https://api.aureahub.com/v1/transactions/', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
    body: JSON.stringify(request) // the same request on every retry
  });
  const body = await res.json();
  if (res.status === 409 && body.details?.code === 'IDEMPOTENCY_IN_PROGRESS') return retryLater();
  if (res.status === 409 && body.details?.code === 'IDEMPOTENCY_OUTCOME_UNKNOWN') return checkTheSendFirst(); // never resend blindly
  return body; // on a retry, the first answer: res.headers.get('idempotency-replayed') === 'true'
}
```

## Idempotency-Key on Bank Ramp Requests

[Onboarding Session](https://docs.aureahub.com/docs/payin-session.md), [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md) (and its alias `/initiate-deposit`), [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md), [Create a Payout Quote](https://docs.aureahub.com/docs/payout-quote.md) and [Pay Out a Quote from Your Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md) accept an optional `Idempotency-Key` header of up to 255 letters, digits, `-` or `_`. Make one key per operation — a UUID works — store it with the pending action, and send the same key with the same body on every retry. Every response of these endpoints carries `idempotency-replayed`: `true` when it is a stored answer, `false` otherwise.

| Request | Answer |
| --- | --- |
| **No header** | Behaves as it always has: every request runs. |
| **A new key** | Runs, and Aurea keeps the answer when the request succeeds. |
| **The same key and the same body, after a success** | The stored answer — same status, same body — with `idempotency-replayed: true`. Nothing is created again and the bank ramp is not called. |
| **The same key and a different body** | `409` with `details.code` `IDEMPOTENCY_KEY_REUSED`. Nothing runs. |
| **The same key while the first request is still running** | `409` `IDEMPOTENCY_IN_PROGRESS`. Wait a moment and send it again. |
| **The same key and the same body, after a refusal or a failure** | Runs again: only a successful answer is kept. A payout retried this way sends the bank ramp the request of the first attempt again, so the bank ramp does not see a second payout. |
| **A key with other characters, or longer than 255** | `400` `IDEMPOTENCY_KEY_INVALID`. Nothing runs. |

A key belongs to one user and one endpoint: the same key sent by another user, or to another endpoint, is a different key. Two bodies are the same when they have the same fields and values, in any order.

```javascript
// Create the key once, keep it with the pending payout, and send it again on every retry
const idempotencyKey = crypto.randomUUID();

async function createPayout() {
  const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payout/initiate', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
    body: JSON.stringify(payoutRequest) // the same body on every retry
  });
  if (res.status === 409) return retryLater(); // IDEMPOTENCY_IN_PROGRESS, or the key was used for another body
  return res.json(); // the first answer, even when this is a retry
}
```

## What Happens on a Retry

| Call | Repeating it |
| --- | --- |
| GET requests | Safe — they don't change anything. |
| POST /v1/transactions/ (custodial wallet) | Without `Idempotency-Key`, sends a second transaction. With the key of the first request, answers with the first transaction. |
| POST /v1/swap/execute | Without a key, sends, or prepares, a second swap. With the key of the first request, answers with the first answer. |
| POST /v1/ramp/bank/payout/initiate | Without `Idempotency-Key`, creates a second payout session. With the key of the first request, answers with the first payout. |
| POST /v1/ramp/bank/payin/onboard-session | Without a key, returns the user's unexpired session or creates a new one. With the key of the first request, answers with the first answer. |
| POST /v1/ramp/bank/payin/initiate | Without a key, assigns the virtual IBAN again; when the bank ramp returns the same payment method, Aurea updates the existing record. With the key of the first request, answers with the first answer. |
| POST /v1/ramp/bank/payin/checkout | Card top-up is switched off: every request answers `403`. |
| Broadcasting a prepared transaction | A prepared transaction can be broadcast once; another attempt is rejected with `409` `PREPARED_TX_ALREADY_USED`. A signature from another key spends nothing: it is refused with `422` `WRONG_SIGNER`, and the right signature still goes through (a gasless permit is the exception — see [Broadcast Transaction](https://docs.aureahub.com/docs/tx-broadcast.md)). |
| Tenant-signed requests | Each signature is accepted once, so a retry has to be signed again — see [Authentication](https://docs.aureahub.com/docs/authentication.md). |

## Agent Payment Executions

`POST /v1/agent-payments/executions` accepts an optional `idempotencyKey` (up to 255 characters). If your tenant already has an execution with that key, Aurea returns that execution unchanged instead of running the payment again. It doesn't compare the rest of the request, and it doesn't return an error.

```javascript
// Create the key once, store it with the pending payment, and reuse it on every retry
const idempotencyKey = crypto.randomUUID();

const res = await fetch('https://api.aureahub.com/v1/agent-payments/executions', {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ ...executionRequest, idempotencyKey })
});
```

## Retrying Safely

- Send an `Idempotency-Key` wherever the route takes one: a retry with the same key and request can't run twice.
- If a request that moves money times out or the connection drops and you sent no key, don't resend it blindly. First check whether it went through — for example in [List Transactions](https://docs.aureahub.com/docs/tx-list.md), [List Payout Transactions](https://docs.aureahub.com/docs/payout-list.md) or the [Unified Feed](https://docs.aureahub.com/docs/agg-tx-list.md).
- Prevent double submission in your UI (disable the button until the response arrives).
- On `429`, wait for `retry-after` before retrying — see [Rate Limiting](https://docs.aureahub.com/docs/rate-limiting.md).

---

Web version: https://docs.aureahub.com/#idempotency
