Skip to content
In this section

Idempotency & retries

Safely retry mutating Fynex API calls without creating duplicates.

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.

Your backendFynexIdempotency-Keyresponsesame key1st requestPOST /initialize-paymentKey: abc…202 AcceptedPayment created2nd requestPOST /initialize-paymentKey: abc…200 OKReplay of original response

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

Generating keys

Use a UUID v4 from any standard library:

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

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:

{ "error": "Idempotency-Key reused with a different currencyCode: original=USD, request=EUR" }
{ "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 — distinguish retryable from permanent failures
  • Polling & SSE — poll for the canonical payment state