Core conceptsIdempotency & errors

Idempotency & Errors

One dedup key (the Idempotency-Key header), one replay contract, and typed string error codes you branch on. Retries are safe when you do them right.

The two rules that make retries safe

Send a partner-generated UUID in the Idempotency-Key HTTP header on every mutating call, and branch your error handling on the typed string in error.code (never on a bare HTTP number). Those two habits turn a flaky network into a non-event: you can retry a payment or a top-up without ever double-acting.

This page covers the model and the codes you will hit most. The complete code-by-code table lives at Error Reference.

Idempotency: one key, one meaning

The Idempotency-Key HTTP header is the single deduplication key for the whole API. Nothing else dedupes a request.

header
Idempotency-Keystring
Required

A partner-generated UUID. Required on every mutating call (POST, and any state-changing path). 24h TTL. One key represents one logical operation, stable across your retries and unique across different operations.

The key lives in the HTTP header, never in the request envelope. There is no meta.idempotency_key field. Generate the UUID once when you first attempt the operation, store it next to your local order record, and reuse that same value on every retry of that one operation.

Same key, same payload: byte-identical replay

Send the same key with the same request payload and Feddi replays the original outcome. You get the original HTTP status and the byte-identical response body, plus a flag on the envelope:

meta.idempotency_replayedboolean

true on a replay. The work already ran once; this response is a copy of that first result, not a fresh execution.

Read that flag and handle the replay without re-acting. A replay is your signal to not re-debit the wallet, not re-print the receipt, and not re-credit the top-up. The money moved exactly once; your side effects must too.

# First send and every retry use the SAME Idempotency-Key.
curl -X POST https://api.feddi.io/v1/partner/payments \
  -H "Authorization: Bearer $TERMINAL_JWT" \
  -H "Idempotency-Key: 7c1f9a2e-3b40-4d21-9e88-0a1b2c3d4e5f" \
  -H "Content-Type: application/json" \
  -d @payment.json

Same key, different payload: a typed conflict

Reuse a key with a different payload and Feddi rejects it with IDEMPOTENCY_KEY_REUSED (HTTP 422). This signals that your key generation is broken: a key must be stable for one logical operation and unique across different ones. Two genuinely different operations sharing a key would otherwise let the second one silently inherit the first one's result, so the API refuses.

If you see IDEMPOTENCY_KEY_REUSED, do not paper over it by minting a fresh key and retrying. Fix the bug in how you derive keys. A reused key on a changed payload means two operations collided, and the second one was never executed.

partner_request_id is correlation, not dedup

meta.partner_request_id in the request envelope is your correlation id. It is for tracing and reconciliation, never for deduplication. The distinction is sharp:

Concurrent sends of the same key

Fire the same key twice at once (a double-tap, a retry that races the original) and exactly one request executes. The rest wait for the winner and then replay its result. You never get two debits from one key, even under a race.

Defense in depth on payments

Idempotency is the outer guard. POST /payments also protects the inner steps: the OTP is consumed atomically with the debit, so a failed debit (for example INSUFFICIENT_FUNDS) does not consume the OTP and the credential stays retryable. QR nonces are single-use; a replayed nonce returns CREDENTIAL_EXPIRED_OR_REPLAYED. Read Identity & Credentials for the credential lifecycle.

Inbound provider webhooks are a separate contract

The header rule above is the outbound contract: you calling Feddi. Inbound provider webhooks (a settlement notification arriving at Feddi) follow a separate contract. They carry no Idempotency-Key header and deduplicate on the provider's own settlement reference. Do not assume the outbound idempotency model applies to inbound delivery. See Events & Reconciliation for the verification contract.

Errors: typed string codes you branch on

Every error is a typed object in the response envelope, never a bare HTTP number:

{
  "ok": false,
  "data": null,
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "Actual and promotional balance cannot cover the debit.",
    "details": { "shortfall_minor": 1500, "available_actual_minor": 1902, "available_promo_minor": 0, "currency": "QAR" }
  },
  "meta": { "request_id": "req_01J0B...", "api_version": "2026-06-01" }
}

Branch your logic on error.code, the string enum. Read error.message for a human-readable summary, and read error.details for the machine-actionable specifics: the shortfall on a declined payment, the valid set on an unsupported enum, the candidate programs on an ambiguous customer. Typed errors that reference a valid set always echo it in details so you can recover without a second round trip.

Never parse error.message to decide behavior. The message text can change; the code is the contract. The HTTP status is a coarse hint for proxies and logs, not your branch condition.

The codes you will hit most

INVALID_API_KEYstring

HTTP 401. Missing or invalid x-api-key, or an expired or malformed terminal JWT. Re-authenticate and refresh the terminal JWT.

FORBIDDENstring

HTTP 403. Valid key, but not authorized for this merchant or branch scope (or a PENDING_PROOF wallet on a payment). Check your tenant scope, or route the customer to enrollment.

NOT_FOUNDstring

HTTP 404. Resource absent, or cross-tenant (collapsed to 404 so existence and PII never leak). Confirm the ids belong to your tenant.

VALIDATION_ERRORstring

HTTP 400. Malformed body, missing field, or a domain rule was violated. Read details for field errors, fix, then retry with a NEW key.

INSUFFICIENT_FUNDSstring

HTTP 402. Actual plus promotional balance cannot cover the debit. Route to top-up or recovery. The credential is NOT consumed, so the retry is valid after a reload.

IDEMPOTENCY_KEY_REUSEDstring

HTTP 422. Same key, different payload. Fix key generation. Do not retry with a fresh key as a workaround.

WALLET_PROGRAM_AMBIGUOUSstring

HTTP 422. The customer resolves to more than one program. Pass wallet_program_id from details.candidates.

CURRENCY_NOT_SUPPORTEDstring

HTTP 422. No active provider for the requested currency. Use a currency from details.supported.

RATE_LIMITEDstring

HTTP 429. Too many requests. Back off per the Retry-After header and details.

INTERNAL_SERVER_ERRORstring

HTTP 500. A Feddi-side error. Retry with the SAME key (safe replay). Escalate if it persists.

This is the working subset. The full enum, including credential-type and capability errors, is at Error Reference.

INTERNAL_SERVER_ERROR is replay-safe

A 500 does not mean your operation failed and must be re-issued fresh. It means Feddi could not confirm the outcome to you. Retry the exact same request with the same Idempotency-Key. If the work already committed, you get the real result replayed; if it did not, the retry runs it once. Either way you end with exactly one effect.

Never retry a 500 with a fresh Idempotency-Key. A fresh key turns a safe replay into a possible double-debit, because the API has no way to recognize the retry as the same operation.

Where to go next