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.
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:
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
{
"ok": true,
"data": {
"payment_id": "pay_01HZX...",
"status": "completed",
"amount_minor": 3402,
"debited_promo_minor": 500,
"debited_actual_minor": 2902,
"currency": "QAR"
},
"error": null,
"meta": {
"request_id": "req_01J0A...",
"idempotency_replayed": true,
"api_version": "2026-06-01"
}
}
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:
Fresh request_id, reused key
A REPLAY. No new debit. You get the original result back with idempotency_replayed: true.
Reused request_id, fresh key
A NEW operation. A new debit runs. The repeated partner_request_id is just a correlation collision, not a guard.
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
HTTP 401. Missing or invalid x-api-key, or an expired or malformed terminal JWT. Re-authenticate and refresh the terminal JWT.
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.
HTTP 404. Resource absent, or cross-tenant (collapsed to 404 so existence and PII never leak). Confirm the ids belong to your tenant.
HTTP 400. Malformed body, missing field, or a domain rule was violated. Read details for field errors, fix, then retry with a NEW key.
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.
HTTP 422. Same key, different payload. Fix key generation. Do not retry with a fresh key as a workaround.
HTTP 422. The customer resolves to more than one program. Pass wallet_program_id from details.candidates.
HTTP 422. No active provider for the requested currency. Use a currency from details.supported.
HTTP 429. Too many requests. Back off per the Retry-After header and details.
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.
Only after the API has definitively told you the operation never ran. A VALIDATION_ERROR is the clear case: the request was rejected before any work happened, so you fix the payload and retry with a new key. For anything that might have committed (a timeout, a 500, a dropped connection), reuse the original key and let the replay contract resolve it.
Where to go next
API Conventions
The envelope, headers, and versioning rules every call inherits.
Error Reference
The complete typed-error table, code by code.
Payments & Redemption
The atomic debit where the replay and credential guards matter most.
Events & Reconciliation
The event envelope and the inbound signature-verification contract.