Skip to main content
All articles
Technical13 min read

Payment API error codes: what each code means and whether to fix, retry or wait

Payment API error codes from 400 to 502: the error envelope, the correlationId, each cause and fix, and when to retry with your idempotency key.

ChariPay's payment API error codes follow one simple rule: the HTTP status tells you who has to act, the code tells you what to do. A 4xx error is fixed on your side before any new attempt — except 429, where you just wait for the time given in Retry-After; a 5xx error comes from our side and is retried with the same idempotency key. This guide covers the error envelope and its correlationId, every published code with its cause, its fix and what to do next, then a decision rule and a logging setup that does not flood your alerts.

The error envelope and the correlationId

ChariPay is a Moroccan payment platform, a PSP operated by Chari Money, a payment institution licensed by Bank Al-Maghrib. Every error from its merchant API — the /v1 paths — shares the same envelope:

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amount: must be greater than 0"
  },
  "correlationId": "b0c1e2d3-4f56-7890-abcd-ef0123456789"
}

Three fields, three uses. The code is stable: it is part of the contract, and it is what your program tests. The message is meant for a human: it helps you understand, not decide. Test the code, never the message. The correlationId identifies the request in our logs; it also comes back on successful responses, and if you send your own X-Request-Id header, it is echoed on the response and becomes the correlationId. So generate one identifier per request on your side, send it as X-Request-Id, and any line in your logs will map effortlessly to a line in ours.

One exception to know: the signup and portal endpoints have their own error format, with ERR-XXXX codes. The envelope above applies to the /v1 paths your key calls, as well as to the /checkout/* calls of direct checkout, and that is what the rest of this guide covers.

400 and 401: the request or the key

CodeStatusCauseFix
VALIDATION_ERROR400a field fails validationread the message, fix the field
MISSING_PARAMETER400a required parameter is missingadd it
INVALID_IDEMPOTENCY_KEY400Idempotency-Key missing or too long where it is required, for example on POST /checkout/submit for a reusable sessionsend a valid key
UNAUTHORIZED401API key missing or invalidcheck the X-CHARI-PAY-API-KEY header and the key your configuration loads
INVALID_TOKEN401direct checkout: the vk verify key is invalid or already used, or the session was not verified firstfollow the call order (verify before submit) and never reuse a vk

A 400 means the API refuses the request as it stands. Before you reread your logic, check that the request follows the API's conventions:

  • camelCase fields: orderId, not order_id;
  • an amount in dirhams, in major units, with at most two decimals: 149.90;
  • every URL you declare — return, notification, logo — on public https://, or you get a 400;
  • an expiresAt in the future, in ISO-8601 UTC: 2026-07-31T10:15:00Z;
  • a flat metadata — strings, numbers, booleans — with no nested object or array, at most 4 KB.

Also leave out an optional field rather than sending it as null: the API works with absent fields. A 400 is never retried as is, whatever its code, since the same request will produce the same error. Fix it, then send the corrected request. If a 401 UNAUTHORIZED shows up right after a deployment, check the environment variable that holds the key first: it may be empty, misnamed or truncated.

To wire up your error handling, trigger these errors on a test key — a missing field, an http:// URL, a deliberately wrong key — and read the status and code you get back. Start in test mode: three fields — name, work email, company — then an activation link received by email to choose your password, then the portal, where you create your own test key after re-authenticating. It is free, with no time limit, and your business verification only gates production.

403 PRODUCTION_ACCESS_NOT_ENABLED and FORBIDDEN

403 PRODUCTION_ACCESS_NOT_ENABLED means exactly what it says: you are calling the API with a chari_sk_live_… key while production is not yet enabled on your account. It is not an integration bug; it is an administrative step. Two fixes: go back to your chari_sk_test_… key while you build, or finish the move to production — business verification, review of your application, the MAD 6,000 incl. VAT activation fee paid once — whose go-live checklist is in moving from sandbox to production. Your program should test this exact code, PRODUCTION_ACCESS_NOT_ENABLED, and surface it clearly to your team instead of burying it among generic errors.

403 FORBIDDEN is a different matter: the key is authenticated, but it lacks the permission required for this call. Each key carries per-module permissions; the published example is the CSV export of transactions, which requires the transaction:export permission. The fix is to check, in the portal, the permissions of the key your integration uses, and to call with a key that carries the one the call requires. Do not widen permissions by reflex: give a key what your integration actually calls, and nothing more.

404, 409, 410 and 422: resource, conflict, expiry, state

StatusCodeWhat it meansWhat to do
404OPERATION_NOT_FOUNDno operation matches the identifier you sentcheck the identifier and the key's environment
404ORDER_NOT_FOUNDno payment matches the referencecheck the reference you sent
404SESSION_NOT_FOUNDunknown sessioncheck the sessionId you sent
409IDEMPOTENCY_CONFLICTsame Idempotency-Key, different bodyfix how keys are generated; do not replay as is
409SESSION_ALREADY_CONSUMEDthe session has already been paiddo not submit it again; for a new payment, create a new session, and to give money back, issue a refund
409SESSION_NOT_ACTIVEthe session was canceledcreate a new one if the order still has to be paid
410SESSION_EXPIREDthe session is past its expiresAtcreate a new session
422WALLET_NOT_ACTIVEvalid request, but the wallet involved is not activedo not retry in a loop; contact the team, with the correlationId

On a transaction or a refund, OPERATION_NOT_FOUND can also come from an operationId created in the other environment: the key chooses the environment, and the sandbox and production are isolated.

409 IDEMPOTENCY_CONFLICT points to a bug on your side, not an incident: the same key was used for two different bodies. An idempotency key is derived from the intent — the order and the operation — never from a random draw; the full mechanism is in payment idempotency.

Session codes, for their part, are not incidents: they describe the normal life of a session, consumed only once and valid until it expires or is canceled. They answer, among others, the calls that open and pay the session — the hosted page's calls or, in direct checkout, your server's. The lifecycle that produces them is explained in checkout session expiry and return.

429 RATE_LIMITED: read Retry-After

Publicly exposed endpoints announce their budget in three response headers. Beyond it, the API answers 429 with the RATE_LIMITED code and a Retry-After header.

HeaderWhat it tells you
X-RateLimit-Limitthe limit that applies to the call
X-RateLimit-Remainingwhat you have left before reaching it
X-RateLimit-Resetwhen the counter resets
Retry-Afteron a 429, how long to wait before retrying

What to do comes in two steps: wait for the time given by Retry-After, then replay the same request, with the same idempotency key. For batch jobs — catalog sync, status refreshes, exports — spread your calls and watch X-RateLimit-Remaining instead of waiting for the 429. Bursts of 429 on a read endpoint can reveal overly aggressive polling: a payment's outcome is learned by webhook, and polling is only a fallback. No numeric threshold is published: do not hard-code a limit, read the headers.

5xx and 502 BAAS_CHARI_ERROR: retry with the same key

5xx errors are on our side: retry with your idempotency key rather than creating a new resource. This is exactly the case the three idempotency tools exist for:

  • `Idempotency-Key` on creates: replaying the same value returns the first result if the first attempt went through, with no duplicate.
  • `externalId` on links, sessions, subscriptions and products: a create with an existing externalId returns the existing resource with 200 instead of 201.
  • `refundReference` on refunds: 202 on creation, 200 on replay, never a second refund.

Retry with an increasing delay between attempts, and stop after a few tries to alert a human rather than looping. A create without an idempotency key, replayed after a 5xx, can create a duplicate: that is why every create you send should carry one, starting in the sandbox.

One case looks like an outage and is not: in the sandbox, 502 BAAS_CHARI_ERROR answers a card other than the test card. Retrying is pointless: change the card. The only card accepted in the sandbox is 4918 9141 0719 5005, with CVV 123, any future expiry date and the 3-D Secure code 555; the rest of the test run is in testing payments in test mode.

Payment API error codes: the decision rule

StatusWhat you doRetry?
400fix the fieldafter the fix
401fix the header or the keyafter the fix
403use the right key or permissions, or enable productionafter the fix
404check the identifier you sent and, for an operation, the key's environmentafter the fix
409idempotency conflict: fix key generation; session paid or canceled: a new session if a payment is still duenever as is
410create a new sessionno
422clear the blocking statenot before
429wait for Retry-Afteryes, as is
5xxwait, with an increasing delayyes, with the same idempotency key
502 BAAS_CHARI_ERROR in the sandboxchange the cardno

An API error is not a decline from the bank. A payment declined by the buyer's bank does not show up as an error on your call: creating the session succeeded, it is the payment that did not go through, and you read that in its status. The causes of those failures — abandonment during authentication, a decline from the issuing bank, wrong card details, suspected fraud — are covered in 3-D Secure and success rates.

Log and alert without the noise

For every failed call, log the method and path called, the HTTP status, the code and the correlationId — which carries your X-Request-Id if you send one. Never the API key: a key is a secret, and logs get passed around. Then tune your alerts to what each signal means:

SignalWhat it meansResponse
401 or 403 in productionkey missing or invalid, missing permission, production not enabledimmediate alert: your calls no longer go through
bursts of 429overly aggressive polling or a poorly spread batchalert, then smooth out the calls
repeated 5xxan incident on our sidealert after several consecutive failures, retry with idempotency keys
409 IDEMPOTENCY_CONFLICTa key-generation bughigh-priority ticket
isolated 400data from one specific ordera ticket, without waking anyone up

When you write to us — contact the team — include the correlationId, the time in UTC and the path you called: that is what lets us find your request in our logs. The payment API in Morocco guide puts error handling back into the full integration, and the API documentation lists the possible responses of every endpoint.

Frequently asked questions

What does the 403 PRODUCTION_ACCESS_NOT_ENABLED error mean?

It means you are calling the API with a chari_sk_live_… key while production is not yet enabled on your account. It is not an integration bug: keep working with your test key, or finish the move to production — business verification, review of your application, and the MAD 6,000 incl. VAT activation fee paid once.

What is the correlationId in an error response for?

It identifies your request in our logs. It comes back on every response, successes included, and carries your X-Request-Id header if you send one. Log it systematically and include it in any support request, along with the time in UTC and the path called: that is what lets us find your request in our logs.

Should I retry a request after a 500 error?

Yes. 5xx errors are on our side: retry with your idempotency key rather than creating a new resource. The same Idempotency-Key, the same externalId or the same refundReference guarantee that no duplicate is created if the first attempt went through. Space out your retries with an increasing delay and alert a human after a few failures.

What should I do after a 429 RATE_LIMITED error?

Wait for the time given by the Retry-After header, then replay the same request with the same idempotency key. To avoid the next one, watch X-RateLimit-Remaining, spread out your batch jobs and replace polling with webhooks: a payment's outcome is learned through a signed event, not by calling the API in a loop.

Why does the sandbox return 502 BAAS_CHARI_ERROR?

Because the card you entered is not the test card. In the sandbox, only the card 4918 9141 0719 5005 is accepted, with CVV 123, any future expiry date and the 3-D Secure code 555. Any other card gets 502 BAAS_CHARI_ERROR: retrying changes nothing, so change the card.

What does the 409 IDEMPOTENCY_CONFLICT error mean?

It means the same Idempotency-Key was sent with a body different from the first request. It is a bug on your side, not a network incident: an idempotency key is derived from the intent — the order and the operation — and a different request calls for a different key. Fix how keys are generated instead of replaying as is.

What is the API's rate limit?

No numeric threshold is published. Publicly exposed endpoints announce their budget in the X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers, and answer 429 RATE_LIMITED with Retry-After beyond it. Read those headers rather than hard-coding a limit: your integration will adapt on its own.

Does an API error mean the bank declined the payment?

No. An API error means your request did not go through. A payment declined by the buyer's bank is something else: the session was created, the payment did not go through, and you read that in its status. The causes of those declines lie with authentication and the issuing bank, not with your code.

Next step

Trigger these errors in the sandbox — a missing field, an http:// URL, a wrong key, an expired session, the same idempotency key with a different body, externalId included — and check that your code reacts the way the decision rule says it should: Start in test mode. Then wire up correlationId logging, and prepare the switch with moving from sandbox to production.

Written by ChariPay team.

Read next

Technical5 min read

Never charge twice — idempotency explained

Payment idempotency explained: how the Idempotency-Key header and externalId stop you charging a customer twice when the network drops mid-call.

Ready to try it yourself?

Create your account and take test-mode payments today: it is free, with no approval to wait for. A question? Our team supports merchants and developers alike.

  • Free, no commitment
  • Test mode from sign-up
  • No approval to wait for