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:
{
"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
| Code | Status | Cause | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | a field fails validation | read the message, fix the field |
MISSING_PARAMETER | 400 | a required parameter is missing | add it |
INVALID_IDEMPOTENCY_KEY | 400 | Idempotency-Key missing or too long where it is required, for example on POST /checkout/submit for a reusable session | send a valid key |
UNAUTHORIZED | 401 | API key missing or invalid | check the X-CHARI-PAY-API-KEY header and the key your configuration loads |
INVALID_TOKEN | 401 | direct checkout: the vk verify key is invalid or already used, or the session was not verified first | follow 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, notorder_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 a400; - an
expiresAtin 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
| Status | Code | What it means | What to do |
|---|---|---|---|
| 404 | OPERATION_NOT_FOUND | no operation matches the identifier you sent | check the identifier and the key's environment |
| 404 | ORDER_NOT_FOUND | no payment matches the reference | check the reference you sent |
| 404 | SESSION_NOT_FOUND | unknown session | check the sessionId you sent |
| 409 | IDEMPOTENCY_CONFLICT | same Idempotency-Key, different body | fix how keys are generated; do not replay as is |
| 409 | SESSION_ALREADY_CONSUMED | the session has already been paid | do not submit it again; for a new payment, create a new session, and to give money back, issue a refund |
| 409 | SESSION_NOT_ACTIVE | the session was canceled | create a new one if the order still has to be paid |
| 410 | SESSION_EXPIRED | the session is past its expiresAt | create a new session |
| 422 | WALLET_NOT_ACTIVE | valid request, but the wallet involved is not active | do 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.
| Header | What it tells you |
|---|---|
X-RateLimit-Limit | the limit that applies to the call |
X-RateLimit-Remaining | what you have left before reaching it |
X-RateLimit-Reset | when the counter resets |
Retry-After | on 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
externalIdreturns the existing resource with200instead of201. - `refundReference` on refunds:
202on creation,200on 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
| Status | What you do | Retry? |
|---|---|---|
| 400 | fix the field | after the fix |
| 401 | fix the header or the key | after the fix |
| 403 | use the right key or permissions, or enable production | after the fix |
| 404 | check the identifier you sent and, for an operation, the key's environment | after the fix |
| 409 | idempotency conflict: fix key generation; session paid or canceled: a new session if a payment is still due | never as is |
| 410 | create a new session | no |
| 422 | clear the blocking state | not before |
| 429 | wait for Retry-After | yes, as is |
| 5xx | wait, with an increasing delay | yes, with the same idempotency key |
502 BAAS_CHARI_ERROR in the sandbox | change the card | no |
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:
| Signal | What it means | Response |
|---|---|---|
401 or 403 in production | key missing or invalid, missing permission, production not enabled | immediate alert: your calls no longer go through |
bursts of 429 | overly aggressive polling or a poorly spread batch | alert, then smooth out the calls |
repeated 5xx | an incident on our side | alert after several consecutive failures, retry with idempotency keys |
409 IDEMPOTENCY_CONFLICT | a key-generation bug | high-priority ticket |
isolated 400 | data from one specific order | a 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.