Skip to main content

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.
required
A partner-generated UUID. Required on every mutating call. Optional on the side-effect-free read-shaped POSTs (identify, redeem balance check, evaluate incentives): sending it there is accepted, just not required. 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.

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 minted by POST /payments/otp/send (the trigger leg) 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 Webhooks 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:
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). On a transaction read, 403 means the transaction exists but belongs to another integration. Check your tenant scope, or route the customer to enrollment.
HTTP 404. Resource genuinely absent. Some lookups also collapse cross-tenant ids to 404 so existence and PII never leak, but transaction reads do not: there a cross-tenant id is a 403 FORBIDDEN, and 404 means the id is genuinely unknown. 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.

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.

Webhooks

The event catalog, the event envelope, and the signature-verification contract.