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

# Idempotency

> Safely retry create calls without double payments

`POST /v1/payouts` is idempotent. Retry the same logical request as many
times as you need — the API will return the original payout instead of
creating a new one.

## How keys are picked

Two sources, in order:

1. **`Idempotency-Key` header**, if you send one. Any opaque string is fine
   — a ULID, a UUID, or a deterministic key derived from your payroll run
   (e.g. `payroll-co-2026-05-emp-001`).
2. **`external_id` from the body**, if no header is provided. Every payout
   already requires a unique `external_id`, so this gives you idempotency
   "for free" without managing a separate key.

Either way, the key is **scoped per client**, not per API key. Two different
tenants can use the same `external_id` without colliding — and **rotating your
own key does not turn a retry into a second payout**. Your client identity is
stable across key rotation; see [Authentication](/authentication).

## Replay semantics

| Scenario                                         | Response                        |
| ------------------------------------------------ | ------------------------------- |
| First call with key K, body B                    | `201 Created` — payout created  |
| Replay with key K, body B (identical)            | `200 OK` — same payout returned |
| Same key K, **different** body                   | `409 IDEMPOTENCY_KEY_REUSED`    |
| Same `external_id` (no idempotency header match) | `409 EXTERNAL_ID_CONFLICT`      |

<Note>
  Note the status code: idempotent replays return **`200`**, not `201`. This
  lets your client tell "I just created this" from "I called this same request
  again".
</Note>

## Recommended pattern

Derive an idempotency key from the payroll run that produced the
`net_salary` you're paying out. A good key is **deterministic** (so retries
match) and **unique per logical payout** (so unrelated requests don't
collide).

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.clevis.dev/v1/payouts \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Idempotency-Key: payroll-co-2026-05-emp-001" \
    -H "Content-Type: application/json" \
    -d '{
      "amount": "4600000.00",
      "currency": "COP",
      "country": "CO",
      "external_id": "payroll-co-2026-05-emp-001",
      "beneficiary": { ... }
    }'
  ```

  ```python Python theme={null}
  import httpx

  idempotency_key = f"payroll-{country}-{year}-{month:02d}-{employee_id}"

  resp = httpx.post(
      "https://api.clevis.dev/v1/payouts",
      headers={
          "Authorization": "Bearer YOUR_API_KEY",
          "Idempotency-Key": idempotency_key,
      },
      json={
          "amount": net_salary,
          "currency": "COP",
          "country": "CO",
          "external_id": idempotency_key,   # also works as the key
          "beneficiary": { ... },
      },
  )

  if resp.status_code == 200:
      print("Replay — payout already existed")
  elif resp.status_code == 201:
      print("Created — first time we saw this key")
  elif resp.status_code == 409:
      error = resp.json()["error"]
      if error["code"] == "IDEMPOTENCY_KEY_REUSED":
          raise RuntimeError("Same key, different body — bug in caller")
      if error["code"] == "EXTERNAL_ID_CONFLICT":
          raise RuntimeError("external_id already used for a different payout")
  ```

  ```javascript Node.js theme={null}
  const idempotencyKey = `payroll-${country}-${year}-${month}-${employeeId}`;

  const resp = await fetch("https://api.clevis.dev/v1/payouts", {
    method: "POST",
    headers: {
      Authorization: "Bearer YOUR_API_KEY",
      "Idempotency-Key": idempotencyKey,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      amount: netSalary,
      currency: "COP",
      country: "CO",
      external_id: idempotencyKey,
      beneficiary: { /* ... */ },
    }),
  });

  switch (resp.status) {
    case 201:
      console.log("Created");
      break;
    case 200:
      console.log("Idempotent replay");
      break;
    case 409: {
      const { error } = await resp.json();
      if (error.code === "IDEMPOTENCY_KEY_REUSED") {
        throw new Error("Same key with a different body");
      }
      if (error.code === "EXTERNAL_ID_CONFLICT") {
        throw new Error("external_id already used");
      }
    }
  }
  ```
</CodeGroup>

## What counts as "identical body"?

The whole JSON request body. If you change any field — amount, beneficiary
details, metadata — and reuse the same `Idempotency-Key`, you get
`409 IDEMPOTENCY_KEY_REUSED`. That is intentional: it surfaces caller bugs
(e.g. you re-derived the amount but kept the key) instead of silently paying
out something different from the original intent.

## `Idempotency-Key` vs. `external_id`

|                                                  | `Idempotency-Key` | `external_id`  |
| ------------------------------------------------ | ----------------- | -------------- |
| Where it lives                                   | Request header    | Request body   |
| Required?                                        | Optional          | **Required**   |
| Visible on the payout resource                   | No                | Yes            |
| Used to dedupe                                   | Yes (preferred)   | Yes (fallback) |
| Searchable via `GET /v1/payouts?external_id=...` | No                | **Yes**        |

If you only ever set `external_id` (and skip the header), you still get full
idempotency. The header exists for cases where you want to retry the same
logical create with a fresh `external_id` — uncommon, but supported.

## Idempotency window

Keys are remembered for the lifetime of the in-memory store. Since the v1
mock store is process-local and ephemeral, restarting the API drops all
idempotency keys. **Don't rely on the mock for cross-process deduplication
testing.** The real dLocal-backed implementation will use a persistent
window (matching dLocal's own retention).
