> ## Documentation Index
> Fetch the complete documentation index at: https://docs.spendin.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency

> Retry any write safely without creating it twice.

Networks fail after the server has already done the work. A timeout on
`POST /v1/payouts` tells you nothing about whether money is moving. Idempotency
keys make the retry safe: the second request returns the first one's result
instead of creating a second payout.

**Every authenticated `POST`, `PUT`, and `PATCH` requires an `Idempotency-Key`
header.** It is not optional, and a request without one is rejected before any work
happens.

The one exception is `POST /v1/banks/resolve`, which is a `POST` purely to keep an
account number out of a query string. It writes nothing, so there is nothing to make
idempotent.

```bash theme={null}
curl -X POST "$SPENDIN_BASE_URL/v1/payouts" \
  -H "X-API-Key: $SPENDIN_API_KEY" \
  -H "Idempotency-Key: 1b4e28ba-2fa1-4d1b-883f-176f8f2f4e1c" \
  -H "Content-Type: application/json" \
  -d '{ ... }'
```

## Choosing a key

Use a UUID v4, generated once per logical operation and persisted alongside
whatever you are creating. The natural pattern is to store it on your own record
before you call us:

```typescript theme={null}
const idempotency_key = existing_order.spendin_idempotency_key ?? randomUUID();
await orders.update(existing_order.id, { spendin_idempotency_key: idempotency_key });

const response = await fetch(`${base_url}/v1/payouts`, {
  method: 'POST',
  headers: {
    'X-API-Key': api_key,
    'Idempotency-Key': idempotency_key,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payout),
});
```

Now a retry after any failure — timeout, crash, redeploy — reuses the same key
and cannot double-pay.

<Warning>
  Do not derive keys from something that repeats, such as the order total or the
  beneficiary's phone number. Two genuinely different payouts that collide on a
  key will silently return the first payout's response, and the second will never
  be created.
</Warning>

## What each outcome looks like

Keys are scoped to your tenant and retained for 24 hours.

<AccordionGroup>
  <Accordion title="First use — the request runs" icon="circle-play">
    The handler executes and the response is cached against the key.
  </Accordion>

  <Accordion title="Replay after completion — cached response" icon="clone">
    You get byte-for-byte the original response body and status code. No second
    payout is created. This is the case that makes retries safe.
  </Accordion>

  <Accordion title="Replay while still in flight — 409" icon="triangle-exclamation">
    The original request has not finished yet, so there is no result to return
    and running it again would be unsafe. Back off and retry.

    ```json theme={null}
    {
      "error": {
        "code": "REQUEST_IN_PROGRESS",
        "message": "A request with this idempotency key is already being processed.",
        "details": [],
        "request_id": "req_01HX..."
      }
    }
    ```
  </Accordion>

  <Accordion title="Missing header — 400" icon="ban">
    ```json theme={null}
    {
      "error": {
        "code": "MISSING_IDEMPOTENCY_KEY",
        "message": "Idempotency-Key header is required for write operations.",
        "details": [
          { "field": "Idempotency-Key", "issue": "Header must be a valid UUID v4" }
        ],
        "request_id": "req_01HX..."
      }
    }
    ```
  </Accordion>
</AccordionGroup>

## Failed requests release the key

If a request fails, its key is cleared rather than held. Retrying with the same
key runs the operation again, which is what you want — a payout rejected for a
compliance reason you have since fixed should go through on retry, not replay
the old failure forever.

The consequence: a key only pins a response once the request has *succeeded*.

## After 24 hours

Keys expire 24 hours after first use. Reusing one after that treats the request as
new and creates a second payout. Retries should happen well inside that window;
anything older should be reconciled by looking the payout up with `GET /v1/payouts`
rather than replayed.

<Note>
  `merchant_reference` is the durable handle, not the idempotency key. Set it on
  every payout to something meaningful in your system — it is returned on the payout,
  included in webhook payloads, and searchable long after the idempotency key has
  expired.
</Note>
