Error handling
Why a request is refused, why an accepted payment still fails, and how to tell the two apart — HTTP statuses, error codes, and what to retry.
Two kinds of failure
A payment can fail in two completely different places, and they reach you through two different channels. Getting this distinction right is most of what error handling on our API is about:
| Rejected at creation | Failed after creation | |
|---|---|---|
| What happened | We refused the request. Nothing was stored. | We accepted the request, then the routing or the provider failed it. |
| You learn about it | Synchronously, in the HTTP response. | Asynchronously, by callback or by polling the status endpoint. |
| HTTP status | 401 / 422 / 400 / 404 / 409 / 5xx |
200 — the creation call succeeded |
| Body | {"detail": "…"} |
PaymentResponse with payment_status: "initiated" |
| Machine-readable code | the HTTP status only | processing_info.error_code |
| Does a payment exist? | No — your payment_id is still free. |
Yes — it is stored, visible, and final. |
Creation endpoints answer 200 with payment_status: "initiated" as soon as
we have accepted and queued the payment. Routing, limit checks and the provider call all happen
afterwards. Never treat the creation response as the result — always wait for a terminal status.
Rejected at creation
Every refusal is an HTTP error whose body is a single detail string. There is no
error-code field on this surface — branch on the HTTP status, and log
detail for your support team.
{
"detail": "Payment already exists"
}
The detail wording is written for a human reading your logs and may change. Some
messages are even built per request (the currency one names your cascade's accepted list). Route
your code on the status; keep the string for humans.
401 — signing and access
All four causes below use status 401; only detail tells them apart, so log it.
detail | Cause | Fix |
|---|---|---|
Unauthorized |
A required header is missing; the wallet_uid or key_id is unknown;
the key is disabled or belongs to another wallet; your IP is not on the key's allow-list;
or the signature does not match. |
Re-check the five headers and re-read Getting started. If you sign correctly but still fail, confirm the source IP with your account manager. |
Invalid timestamp |
X-MP-Timestamp is not an integer. |
Send UTC milliseconds as a plain integer string — not seconds, not ISO-8601. |
Timestamp outside allowed window |
Your clock is more than 5 minutes from ours. | Run NTP on the calling host. Generate the timestamp at send time, not at build time. |
Nonce already used |
That X-MP-Nonce was already seen (within a 5-minute window). |
Generate a fresh random nonce per request. Never reuse one when retrying. |
A retried request needs a new timestamp, a new nonce, and a new signature — but the
same payment_id. Replaying the exact bytes of the original request gives
you 401 Nonce already used; changing the payment_id risks charging the payer twice.
422 — the request body is malformed
Schema validation failed before any business logic ran. This is the one error surface that is fully
machine-readable: detail is a list, one entry per offending field.
{
"detail": [
{
"type": "greater_than_equal",
"loc": ["body", "payment", "amount"],
"msg": "Input should be greater than or equal to 0"
}
]
}
Common causes:
currencyis not a supported ISO 4217 code.amountis negative, or is a string / decimal instead of an integer in minor units.payment_idis empty or longer than 255 characters;cascade_idis longer than 40.- A payout destination field breaks its format — phone number, account number (6–34 chars), username (3–64 chars), card expiry month/year.
- A required object (
payment,card,customer) is missing entirely.
400, 404 and 409 — business rules
| Status | detail | What it means |
|---|---|---|
400 |
Payment already existsInvoice already exists |
That payment_id / invoice_id is already used in your project.
See Idempotency & retries below — this is usually good news, not an error. |
400 |
Currency 'EUR' is not accepted by this cascade. It accepts: MDL. The wallet is in EUR. |
The route you selected does not accept that currency. The message names what it does accept, and your wallet's own currency, so you can tell a wrong request from a misconfigured route. |
400 |
UPI pay-in is only supported in INRPix pay-in is only supported in BRL |
A rail-specific endpoint was called with the wrong currency. |
400 |
Pix pay-in requires the payer's valid CPF in customer.document |
H2H Pix only: the CPF is missing or fails its checksum. We validate it up front so you
get a plain 400 instead of a payment that dies minutes later. |
404 |
Wallet not found |
X-Wallet-UID does not resolve to a wallet. |
404 |
Wallet or Wallet Cascade not found |
The cascade_id is unknown, belongs to a different wallet, or is of the wrong
direction (a pay-in cascade used on a payout endpoint). You also get this when you omit
cascade_id and the wallet has no default cascade for that direction. |
404 |
Gate configuration not found |
Invoices only: unknown gate_id, or a gate belonging to another wallet. |
409 |
Gate alias is ambiguous for this wallet; use the gate UID as gate_id |
Two of your gates share that alias. Send the gate's UID instead. |
Wallet or Wallet Cascade not found covers four different causes with one message. If you
hit it and your IDs look right, the likeliest explanation is a direction mismatch — a pay-in cascade
sent to a payout endpoint, or the reverse.
5xx — a fault on our side
A 500 or 502 is ours to fix, not a signal about your request. Retry with
backoff, keeping the same payment_id. Two exceptions worth recognising:
Something wrong with gate configuration…— the gate is misconfigured. Retrying will not help; contact your account manager.- Repeated
5xxon one currency only — the currency may not be enabled on the platform. Also permanent until we act.
Failed after creation
Once you have a payment_uid, everything else arrives asynchronously. Read the outcome from
the payment's status, and the reason from processing_info:
/api/payment/status{
"uid": "0f2c7d1e-…",
"merchant_payment_id": "order-10231",
"currency": "KRW",
"amount": 50000,
"status": "error",
"processing_info": {
"error_code": "amount_incorrect",
"error_message": "Payment amount exceeds limit",
"created_at": "2026-09-01T10:15:04Z",
"finish_at": "2026-09-01T10:15:05Z"
}
}
status—declinemeans the payment was refused (by the payer, the provider, or your balance);errormeans it failed on the way. Both are terminal; neither moves money.processing_info.error_code— a fixed enum. This is the field to branch on.processing_info.error_message— a human-readable sentence. It is not derived from the code and its wording varies by route. Display it, log it, never parse it.
If you set workflow_hooks.callback_url, we push this exact body to you on every status
change — you do not have to poll to learn about a failure. Treat the callback as the source of truth
and polling as your reconciliation fallback.
Error code reference
Codes are grouped by who has to act. "Retry" means: create a new payment with a new payment_id.
Your request or your configuration
| Code | What happened | Retry? |
|---|---|---|
invalid_request |
The route could not serve this payment: the terminal behind your cascade doesn't support this direction or this currency, or the route is misconfigured. Note the currency check at creation covers the cascade; the terminal behind it is checked here. | No — contact your account manager. |
amount_incorrect |
The amount is below the minimum or above the maximum for that route and currency. | Yes, with an amount inside the limits. |
insufficient_funds |
Payouts: your wallet's payout balance does not cover the amount plus fees. Pay-ins: the payer's instrument had insufficient funds. | After topping up (payout) — otherwise no. |
We do not validate the amount against the route's limits while answering your request, so an
out-of-range amount comes back as an amount_incorrect payment rather than a
400. Ask your account manager for the min/max per currency and validate on your side if
you want to catch it earlier.
The provider or the network
| Code | What happened | Retry? |
|---|---|---|
channel_decline | The provider declined the payment. | Rarely worth it — the reason usually persists. |
channel_timeout | The provider did not answer in time. | Yes, after a short delay. |
channel_unavailable | The provider is down or refusing traffic. | Yes, with backoff. |
network_error | We could not reach the provider. | Yes, with backoff. |
force_timeout | The payer did not complete payment before the invoice expired, so we stopped waiting. Distinct from channel_timeout: nothing timed out on the provider side. | Yes — create a new invoice for a new attempt. |
channel_invalid_request | The provider rejected the request we built. | No — report it to us with the payment_uid. |
channel_unknown | The provider returned something we could not map. | No — report it to us. |
internal_error | A fault on our side. | Yes, with backoff. |
The payer or their instrument
| Code | What happened | Retry? |
|---|---|---|
suspected_fraud | Blocked by an anti-fraud rule. | No. |
channel_not_available_for_customer | This method is not available to that payer. | Offer a different method. |
invalid_card_bin | The card's issuing range is not accepted on this route. | Ask for a different card. |
phone_number_not_available | The mobile-money number cannot receive this operation. | Ask for a different number. |
timeout_3ds | The payer did not finish 3-D Secure in time. | Yes — the payer can try again. |
cancelled_by_customer_3ds | The payer abandoned 3-D Secure. | Yes. |
sms_delivery_problem | The confirmation SMS could not be delivered. | Yes. |
sms_too_many_tries | The payer exhausted their confirmation attempts. | No — not immediately. |
limit_error_deposit_streak_cardlimit_error_completed_deposit_card | The payer's card hit a velocity or volume limit. | No — not until the limit window passes. |
Getting more detail
Add ?provide_status_history=true to the status call to receive the full transition chain.
Its reason field distinguishes failures that never reached a provider from failures the
provider reported:
"history": [
{"previous_status": null, "new_status": "initiated", "reason": "payment_created", "trigger": "api"},
{"previous_status": "initiated", "new_status": "processing", "reason": "processing_started", "trigger": "worker"},
{"previous_status": "processing", "new_status": "error", "reason": "channel_update", "trigger": "worker"}
]
failed_internal means we never got as far as the provider — a routing, limit or balance
problem. channel_update means the provider answered and the answer was a failure. That
single distinction resolves most "whose fault was it?" questions before you open a ticket.
Hosted gates: a failed payment is not a failed invoice
On a hosted gate, an invoice may hold several payment attempts. When an attempt fails with a
transient code — network_error, channel_timeout,
channel_unavailable, timeout_3ds, sms_delivery_problem,
insufficient_funds, channel_decline — and the gate's attempt budget is not
yet spent, the invoice returns to unpaid / paying so the payer can try again.
Any other code fails the invoice immediately.
You will receive a callback for the failed payment while the invoice is still live.
Do not cancel the customer's order on that signal. Act on the invoice reaching
payment_failed, expired, canceled or
declined_by_payer; only paid means you were paid.
Poll /api/invoice/status with provide_payments_history=true to see every
attempt and why each one failed.
Idempotency & retries
Your payment_id is the idempotency key. It must be unique within your project, and reuse is
rejected with 400 Payment already exists. That makes the duplicate error your safety net
rather than a problem:
-
A creation call times out or fails at the transport layer
You cannot know whether we stored the payment. Do not generate a new
payment_id. -
Retry the same request with the same
payment_idFresh timestamp, fresh nonce, fresh signature — the body, includingpayment_id, unchanged. -
A
200means it went through now; a400 Payment already existsmeans it went through the first time Both are success. In the second case, look the payment up by your own identifier.
resp = signed_post("/api/payment/payin/card", payload)
if resp.status_code == 400 and "already exists" in resp.json()["detail"]:
# We already have this payment — fetch it instead of creating another.
resp = signed_post("/api/payment/status", {"merchant_payment_id": payload["payment"]["payment_id"]})
payment = resp.json()
Retrying a transport failure reuses the id on purpose. Retrying a payment that
reached decline or error is a new attempt and needs a new
payment_id — the old one is permanently taken.
Handling errors end to end
Python — creationPERMANENT = {"invalid_request", "suspected_fraud", "invalid_card_bin",
"channel_invalid_request", "channel_unknown",
"limit_error_deposit_streak_card", "limit_error_completed_deposit_card"}
resp = signed_post("/api/payment/payin/card", payload)
if resp.status_code == 200:
payment_uid = resp.json()["payment_id"] # our uid; store it next to your order
elif resp.status_code == 401:
raise ConfigError(resp.json()["detail"]) # signing / clock / key — never retry blindly
elif resp.status_code == 422:
raise BadRequest(resp.json()["detail"]) # fix the field named in detail[].loc
elif resp.status_code == 400 and "already exists" in resp.json()["detail"]:
payment_uid = lookup_existing(payload) # see above — this is a success path
elif resp.status_code == 400 or resp.status_code == 404:
raise BadRequest(resp.json()["detail"]) # currency / cascade / gate — permanent
else: # 5xx
schedule_retry(payload) # same payment_id, backoff
Python — the callback
@app.post("/hooks/magicpayments")
async def on_payment(request: Request):
verify_signature(request) # see Getting started
payment = await request.json()
match payment["status"]:
case "success":
credit_order(payment["merchant_payment_id"],
payment["processing_info"]["amount_credited"])
case "decline" | "error":
info = payment["processing_info"]
fail_order(payment["merchant_payment_id"],
code=info["error_code"],
display=info["error_message"],
retryable=info["error_code"] not in PERMANENT)
case _:
pass # initiated / processing — nothing to do yet
return {"ok": True} # anything but 200 makes us retry
Testing & go-live
- Force each creation error once on stage: drop a header (
401), send a negative amount (422), reuse apayment_id(400), send an unsupported currency (400). Confirm your code takes the right branch on each. - Move your server's clock forward six minutes and confirm you handle
Timestamp outside allowed windowrather than looping. - Ask your account manager for a test route that declines, so you exercise the
decline/errorcallback path — not just the happy one. - Verify your callback handler answers
200only after it has durably recorded the update. Any other response is retried, and a handler that crashes after committing but before answering will see the same update again. - Make your callback handler idempotent: the same terminal status can arrive more than once, and a
post-settlement correction sends a fresh callback even when
statusdid not change. - Log
payment_uid,merchant_payment_id, the HTTP status and eitherdetailorerror_codeon every failure. Those are the fields our support team needs to trace a payment.
Getting started covers signing, the callback signature scheme and the full status vocabulary. Each gate guide lists the errors specific to its rail.