# Idempotency & retries

Networks fail. Servers restart. The right response to a flaky call is to **retry safely** — and that's what idempotency keys are for.

## How it works

Every mutating endpoint accepts the `Idempotency-Key` header (a UUID you generate). Fynex remembers the response body for that key and replays it on subsequent calls with the same key, so you can retry without creating duplicate payments, duplicate refunds, or duplicate payouts.

```flow
lanes: Your backend | Fynex
node first 0 0 | 1st request | POST /initialize-payment | Key: abc… | end
node created 1 1 | 202 Accepted | Payment created | | ok
node retry 0 2 | 2nd request | POST /initialize-payment | Key: abc…
node replay 1 3 | 200 OK | Replay of original response | | end ok
edge first created | Idempotency-Key
edge created retry | response
edge retry replay | same key
```

## Where keys are accepted

| Endpoint | Header | Purpose |
|----------|--------|---------|
| `POST /checkout` | `Idempotency-Key` | Don't create duplicate hosted sessions |
| `POST /initialize-payment` | `Idempotency-Key` | Don't double-charge |
| `POST /finalize-payment` | `Idempotency-Key` | Idempotent capture |
| `POST /payments/{id}/capture` | `Idempotency-Key` | Don't double-capture |
| `POST /payments/{id}/refund` | `Idempotency-Key` | Don't double-refund |
| `POST /payouts` | Body field `idempotencyKey` | Don't double-pay out |
| `POST /payees` | `Idempotency-Key` (optional) | Don't create a second payee |
| `POST /payees/setup` | `Idempotency-Key` (optional) | Don't create a second payee and bank account |
| `POST /payees/{id}/payout-methods` | `Idempotency-Key` (optional) | Don't add a second bank account |
| `POST /webhooks` | `Idempotency-Key` (optional) | Get the signing secret back after a lost response |
| `POST /split-rules` | `Idempotency-Key` (optional) | Don't create a second rule |

Replay semantics depend on the endpoint — see each endpoint's docs. On the five create routes above that mark it optional — payees, payee setup, payout methods, webhooks, split rules — the key may be any string of at most 255 characters, and its namespace is your account. A retry with the same key and the same body answers `201` with what the first request created, as it is now (a webhook includes its current signing secret). The same key with a different body, or on another route, answers `409` with `code` `idempotency_key_reused`. A request that failed recorded nothing, so its key can be sent again. Keys do not expire: mint a new one for each new object. The `Idempotency-Key` on `/finalize-payment` is required as a valid UUID but is not used for replay; the payment's state machine itself prevents double-finalize. `POST /payouts` is special: idempotency lives in the body field `idempotencyKey`, not the header (see [Payouts](https://docs.fynex.ai/payments-api/v2/docs#tag/payouts)).

## Generating keys

Use a UUID v4 from any standard library:

```bash
key=$(uuidgen)
```

## When to mint a new key vs. reuse

- **One operation → one key.** Generate the key when you start the operation; persist it alongside the order so retries reuse it.
- **A retry of the same operation reuses the same key.** That's the whole point.
- **A new attempt after a definitive failure uses a new key.** If the original returned `400 invalid amount`, that key is now permanently associated with that error — fix the input and use a fresh key.

```js
// Pseudocode for resilient charge logic
async function charge(order) {
  if (!order.idempotencyKey) {
    order.idempotencyKey = randomUUID();
    await db.orders.update(order.id, { idempotencyKey: order.idempotencyKey });
  }

  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      return await fynex.initializePayment({ ...order, key: order.idempotencyKey });
    } catch (err) {
      if (!isRetryable(err)) throw err;
      await sleep(2 ** attempt * 100); // 200ms, 400ms, 800ms
    }
  }
}
```

## Retry strategy

Retry on:

- Network errors (`ECONNRESET`, timeouts, DNS failures)
- HTTP `502 Bad Gateway` (upstream provider hiccup)
- HTTP `503 Service Unavailable`

Don't retry on:

- `400 Bad Request` — fix the payload
- `401 Unauthorized` — fix the token
- `403 Forbidden` — fix the permission/seller
- `404 Not Found` — fix the resource ID
- `409 Conflict` — read the body and decide; usually a state issue, not transient

Use **exponential backoff with jitter**: 200ms → 400ms → 800ms with random jitter, capped at 3–5 attempts.

```js
async function retry(fn, max = 4) {
  for (let i = 0; i < max; i++) {
    try { return await fn(); }
    catch (e) {
      if (!isRetryable(e) || i === max - 1) throw e;
      const base = Math.min(2 ** i * 200, 5000);
      const jitter = Math.random() * base * 0.3;
      await new Promise(r => setTimeout(r, base + jitter));
    }
  }
}
```

> [!CAUTION]
> **Don't retry forever.** If three retries fail, surface the error to your operator and stop. A stuck payment with a known idempotency key can be inspected and resolved manually.

## What gets replayed

Fynex binds the key to the original operation and returns the existing resource instead of creating another one. On `POST /initialize-payment`, a replay returns `200 OK`. For an active APM, Fynex re-reads the existing provider charge and rehydrates its current buyer action: `actionUrl` / `redirectFullPage` for redirects and `paymentInstructions` for a Multibanco payment reference. The payment's canonical status can still advance asynchronously through webhooks or polling.

## Reusing a key with a different body

`POST /initialize-payment` enforces strict idempotency on the high-stakes financial fields of the request: `amount`, `currencyCode`, `countryCode`, `externalOrderRef`, `paymentType`, `paymentMethod`. If any of those values differ from the original request that minted the key, the API returns `409 Conflict` with a concrete reason naming the field and both values:

```json
{ "error": "Idempotency-Key reused with a different currencyCode: original=USD, request=EUR" }
```

```json
{ "error": "Idempotency-Key reused with a different amount: original=4999 minor units, request=9999 minor units" }
```

This matters: a silent replay of the original response on a body mismatch could cause an integrator to charge a customer an amount or currency they did not intend.

Fields that are **not** part of the conflict check (e.g. `billingDetails.addressLine2`, `cardData.cvv`, customer profile metadata, headers like `X-Device-Fingerprint`) can vary between retries, but the replay still refers to the original payment; it does not re-run provider creation with the changed ancillary data. The intent is "same financial transaction is safe to look up again; different financial transaction requires a fresh key."

When you see a `409` from this check:

1. Decide whether you actually want to replay the original payment or create a new one.
2. To replay → re-send the request with the original body unchanged.
3. To create a new payment → mint a **fresh `Idempotency-Key`** (fresh UUID) and send the new body.

## Errors and replays

Error replay behaviour is endpoint-specific and not guaranteed across all operations. For safe recovery from a validation error, fix your input and use a **new** idempotency key regardless of whether the original error was replayed.

## Next steps

- [Errors](https://docs.fynex.ai/payments-api/v2/docs#tag/errors) — distinguish retryable from permanent failures
- [Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse) — poll for the canonical payment state
