# 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.

```
1st request:  POST /initialize-payment  Idempotency-Key: abc...  ─►  202 Accepted (payment created)
2nd request:  POST /initialize-payment  Idempotency-Key: abc...  ─►  200 OK      (replay of original)
```

## 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 |

Replay semantics depend on the endpoint — see each endpoint's docs. 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://api.fynex.ai/payments-api/v2/docs#tag/payouts)).

## Generating keys

Use a UUID v4 from any standard library:

```js
import { randomUUID } from 'node:crypto';
const key = randomUUID();
```

```python
import uuid
key = str(uuid.uuid4())
```

```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://api.fynex.ai/payments-api/v2/docs#tag/errors) — distinguish retryable from permanent failures
- [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse) — poll for the canonical payment state
