MagicPayments Integration Guides

Error handling

ErrorsRetriesIdempotency

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 creationFailed 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.
A 200 does not mean the payment succeeded

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.

Response — 400
{
  "detail": "Payment already exists"
}
Don't parse the message text

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.

detailCauseFix
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.
Signing and retries interact

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.

Response — 422
{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": ["body", "payment", "amount"],
      "msg": "Input should be greater than or equal to 0"
    }
  ]
}

Common causes:

400, 404 and 409 — business rules

StatusdetailWhat it means
400 Payment already exists
Invoice 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 INR
Pix 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.
The 404s are deliberately vague

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:

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:

POST/api/payment/status
Response — a failed payment
{
  "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"
  }
}
The same payload arrives by callback

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

CodeWhat happenedRetry?
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.
Amount limits are not checked at creation

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

CodeWhat happenedRetry?
channel_declineThe provider declined the payment.Rarely worth it — the reason usually persists.
channel_timeoutThe provider did not answer in time.Yes, after a short delay.
channel_unavailableThe provider is down or refusing traffic.Yes, with backoff.
network_errorWe could not reach the provider.Yes, with backoff.
force_timeoutThe 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_requestThe provider rejected the request we built.No — report it to us with the payment_uid.
channel_unknownThe provider returned something we could not map.No — report it to us.
internal_errorA fault on our side.Yes, with backoff.

The payer or their instrument

CodeWhat happenedRetry?
suspected_fraudBlocked by an anti-fraud rule.No.
channel_not_available_for_customerThis method is not available to that payer.Offer a different method.
invalid_card_binThe card's issuing range is not accepted on this route.Ask for a different card.
phone_number_not_availableThe mobile-money number cannot receive this operation.Ask for a different number.
timeout_3dsThe payer did not finish 3-D Secure in time.Yes — the payer can try again.
cancelled_by_customer_3dsThe payer abandoned 3-D Secure.Yes.
sms_delivery_problemThe confirmation SMS could not be delivered.Yes.
sms_too_many_triesThe payer exhausted their confirmation attempts.No — not immediately.
limit_error_deposit_streak_card
limit_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:

Response — history excerpt
"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.

Wait for a terminal invoice status

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:

  1. 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.
  2. Retry the same request with the same payment_id Fresh timestamp, fresh nonce, fresh signature — the body, including payment_id, unchanged.
  3. A 200 means it went through now; a 400 Payment already exists means it went through the first time Both are success. In the second case, look the payment up by your own identifier.
Python — recovering a duplicate
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()
Never retry a failed payment under the same id

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 — creation
PERMANENT = {"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

Related reading

Getting started covers signing, the callback signature scheme and the full status vocabulary. Each gate guide lists the errors specific to its rail.