Errors and rate limits
Every merchant endpoint answers with the same JSON envelope and a machine-readable code. This page lists every code the merchant endpoints return, with its HTTP status, what it means and what to do, plus the rate limits and when to retry.
Response envelope
Every response, success or error, uses the same envelope:
{
"success": true,
"uuid": "6b1f0c3e-2d4a-4f8b-9c1e-7a5d3b2f1e0c",
"code": "SESSION_CREATED",
"message": "...",
"data": { "...": "..." },
"errors": null
}On an error, success is false, code is the error code, data is usually null, and errors lists problems by field (an empty list when there are none):
{
"success": false,
"uuid": "0e9d8c7b-...",
"code": "VALIDATION_ERROR",
"message": "One or more validation errors occurred.",
"data": null,
"errors": [
{ "field": "TotalAmount", "message": "Total amount must be greater than zero" },
{ "field": "Items[0].Quantity", "message": "Item quantity must be greater than zero." }
]
}- Write your code against
codeand the HTTP status, notmessage. Messages are for people and can change. - Field names in
errorsfollow the request structure in PascalCase, for exampleShippingAddress,Items[1].TotalAmount,MerchantUrl.Success. uuididentifies the request. Every response also carriesX-Request-IdandX-Correlation-Idheaders. If you send your ownX-Correlation-Id(a UUID), it is echoed back and used asuuid, so you can match our logs to yours. Quoteuuidwhen you contact support.
Responses without an envelope
A few responses have no JSON body. Don't assume one exists:
| HTTP | When |
|---|---|
401 | Missing, unknown, revoked or expired API key, a key from the other environment, or a publishable pk_ key in X-Api-Key. The header WWW-Authenticate: ApiKey realm="merchant-api" is set |
403 | The caller isn't a merchant at all (for example, a token from another product). A merchant key that only lacks a scope gets the envelope with FORBIDDEN instead |
413 | The request body is larger than 10 MB |
400 | The body isn't valid JSON |
HTTP statuses
| HTTP | Meaning | Retry? |
|---|---|---|
200 | Success | — |
201 | Created (sessions, links, webhook endpoints) | — |
202 | Accepted (webhook ping and replay are queued) | — |
400 | The request is invalid, or the action isn't allowed in the order's status | No. Fix the request |
401 | Authentication failed | No. Fix the key |
403 | The key lacks the scope | No. Use a key with the scope |
404 | Not found, or it belongs to another merchant | No |
409 | Conflict with the current state | Only REQUEST_IN_PROGRESS |
413 | Body too large | No |
422 | Idempotency key reused with a different body | No |
429 | Rate limited | Yes, after Retry-After |
500 | Unexpected server error | With care, see below |
502, 503, 504 | Temporarily unavailable | Yes, with backoff |
Error codes
Any endpoint
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 400 | VALIDATION_ERROR | A field is missing or invalid; errors names each one. Also returned for a page below 1 or a negative pageSize, and an empty or over-long orderReferenceId on lookup by reference | Fix the fields listed in errors |
| 400 | INVALID_REQUEST | The request had no body | Send the JSON body |
| 401 | UNAUTHORIZED | The caller is signed in but has no merchant account behind it | Use a merchant API key |
| 403 | FORBIDDEN | The key doesn't have the scope this endpoint needs | Use a key with the right scope |
| 404 | NOT_FOUND | The order, link, webhook endpoint, delivery or contract doesn't exist, or belongs to another merchant | Check the id; use the Qasimha id, not your reference |
| 409 | INVALID_STATE | The operation conflicts with the current state, for example a change made at the same moment by another request | Read the resource again; if it persists, contact support with the uuid |
| 409 | REQUEST_IN_PROGRESS | A request with this Idempotency-Key is still running | Wait and retry with the same key |
| 422 | IDEMPOTENCY_KEY_REUSED | This Idempotency-Key was used with a different body | Use a new key for a new request. See Idempotency |
| 429 | TOO_MANY_REQUESTS | You exceeded the rate limit | Wait for Retry-After seconds, then retry |
| 500 | INTERNAL_ERROR | Unexpected server error | See Which errors to retry |
Creating orders (/pay/session/create, /pay/pos/create, /pay/qr/create)
/pay/session/create, /pay/pos/create, /pay/qr/create)| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 400 | VALIDATION_ERROR | Missing or invalid field, a basket that doesn't add up, a currencyCode other than SAR on a payment session, or an invalid merchantUrl | Fix the fields in errors. See the basket rules |
| 400 | MERCHANT_REQUIRED | The caller has no merchant of its own and named none. Not returned to API keys | Use a merchant API key |
| 409 | DUPLICATE_ORDER_REFERENCE | orderReferenceId is already used (payment session). If the order is yours, data has its installmentId and paymentUrl | Resume the existing checkout, or use a new reference |
| 409 | MERCHANT_SUSPENDED | Your account can't create orders right now | Contact support |
| 422 | ORDER_DECLINED | Only from the earlier provider-based checkout: the order was created but not approved. data has the installmentId. Today's hosted checkout declines on the page instead | Offer another payment method |
Checkout links (/merchant/checkout-links)
/merchant/checkout-links)| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 400 | INVALID_AMOUNT | No positive amount and no items to compute one, or more than 9999999999.99 | Send a positive amount |
| 400 | AMOUNT_ITEMS_MISMATCH | amount doesn't equal the sum of quantity × unitPrice | Send one or the other, or make them agree |
| 400 | INVALID_ITEM | An item has quantity below 1, a negative unitPrice, or a field that is too long | Fix the item |
| 400 | PHONE_REQUIRED | channel is Link and there is no phone | Send the customer's phone, or use channel: "Qr" |
| 400 | INVALID_ORDER_REFERENCE | orderReferenceId is longer than 255 characters | Shorten it |
| 400 | EJAR_CONTRACT_NUMBER_REQUIRED | Real-estate merchant without ejarContractNumber | Send the Ejar contract number |
| 400 | INVALID_EJAR_CONTRACT_NUMBER | ejarContractNumber is longer than 50 characters | Check the number |
| 400 | VALIDATION_ERROR | merchantUrl breaks a rule | Fix the URL named in errors |
| 404 | MERCHANT_NOT_FOUND | Your merchant account wasn't found | Contact support |
| 409 | DUPLICATE_ORDER_REFERENCE | orderReferenceId is already used. data is empty here | Use a new reference, or look the order up by reference |
| 409 | MERCHANT_SUSPENDED | Your account can't create orders right now | Contact support |
| 409 | NOT_OPEN | Cancel only: the link was already completed, cancelled or has expired | Read the order to see its status |
Orders (/merchant/orders)
/merchant/orders)| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 400 | INVALID_STATUS | On list or export: an unknown status filter. On capture, fulfil, refund or cancel: the action isn't allowed in the order's current status; message names it | Check the allowed actions per status |
| 400 | INVALID_SHIPPED_AMOUNT | Fulfil: shippedAmount is 0, negative, or more than the outstanding amount | Send a positive amount up to totalAmount − fulfilledAmount, or leave it out to fulfil the rest |
| 400 | INVALID_AMOUNT | Refund: amount is 0 or negative | Send a positive amount |
| 400 | AMOUNT_EXCEEDED | Refund: amount is more than refundableAmount; message states the available amount | Refund at most refundableAmount |
| 404 | NOT_FOUND | No such order for your account, or no contract on the order | Check the id |
Webhook endpoints (/merchant/webhooks)
/merchant/webhooks)| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 400 | URL_REQUIRED | No url | Send the URL |
| 400 | URL_INVALID | url isn't a valid absolute URL | Fix the URL |
| 400 | URL_SCHEME | url isn't https:// | Use HTTPS |
| 400 | URL_PRIVATE | url points at a private or local address | Use a public host |
| 400 | URL_TOO_LONG | url is longer than 1,024 characters | Shorten it |
| 400 | URL_USERINFO | url contains a user name or password | Remove the credentials |
| 400 | URL_FRAGMENT | url contains a #fragment | Remove the fragment |
| 400 | INVALID_EVENT | An entry in events isn't a known event type; message lists the known ones | Use names from Events |
| 400 | LIMIT_EXCEEDED | You already have 5 endpoints | Delete one you no longer use |
| 400 | VALIDATION_ERROR | description is longer than 200 characters | Shorten it |
| 400 | INVALID_STATUS | Delivery log: status isn't Pending, Succeeded or Failed | Use one of those |
Plans widget (/widget/plans, publishable key)
/widget/plans, publishable key)| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 401 | PUBLIC_KEY_REQUIRED | No X-Public-Key header | Send your publishable key |
| 401 | INVALID_PUBLIC_KEY | The publishable key is unknown, revoked or expired | Check the key and its environment |
| 400 | INVALID_AMOUNT | amount is missing, not positive, or has more than two decimals | Send a positive amount with at most two decimals |
| 400 | UNSUPPORTED_CURRENCY | currency isn't SAR | Send SAR or leave it out |
Success codes
Successful responses also carry a code: SESSION_CREATED (sessions, POS, QR), LINK_CREATED, LINK_CANCELLED, WEBHOOK_CREATED, WEBHOOK_UPDATED, WEBHOOK_DELETED, SECRET_ROTATED, PING_QUEUED, DELIVERY_REPLAYED, OK (plan quotes) and SUCCESS (everything else). Check success and the HTTP status; don't rely on the exact success code.
Rate limits
| Endpoints | Limit | Counted per |
|---|---|---|
Every merchant endpoint (/pay/…, /merchant/…) together | 100 requests per minute | API key (a portal session counts per merchant) |
GET /widget/plans | 600 requests per minute | Publishable key |
- The window is a fixed one minute. Requests over the limit are refused at once, not queued.
- Each API key has its own budget, so one integration can't use up another's. A separate key per system also separates their limits.
- A refused request gets
429with the codeTOO_MANY_REQUESTSand aRetry-Afterheader in seconds:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
Content-Type: application/json
{ "success": false, "uuid": "...", "code": "TOO_MANY_REQUESTS", "message": "Too many requests. Try again in 37 second(s).", "data": null, "errors": [] }On 429:
- Stop sending requests with that key until
Retry-Afterhas passed. - Then retry with exponential backoff and random jitter if you are refused again.
- When retrying a
POST, reuse the sameIdempotency-Key. A429is refused before the key is recorded, so the retry runs normally.
To stay under the limit, use webhooks instead of polling orders, and cache plan quotes per page.
Which errors to retry
| Retry | Don't retry |
|---|---|
Network errors and timeouts, 429, 409 REQUEST_IN_PROGRESS, 502, 503, 504 | 400, 401, 403, 404, 413, 422, and every other 409: fix the request first |
- Retry with the same
Idempotency-Keyand the same body. - Back off exponentially, for example
min(2^attempt × 500 ms, 8 s)plus up to 250 ms of random jitter, and honourRetry-Afteron429. Give up after a few attempts. - A
500on a write may or may not have done its work. Before you retry a create, look the order up withGET /api/v1/merchant/orders/by-reference/{orderReferenceId}. Before you retry a refund or fulfilment, read the order and comparerefundedAmountorfulfilledAmount. If you retry, use the sameIdempotency-Key; yourorderReferenceIdalso protects you from a second order. - The server SDKs follow these rules for you: they retry network errors,
429,502,503,504and409 REQUEST_IN_PROGRESS, and never retry other4xxor500.
Troubleshooting
My error handler crashes on 401
A 401 from a bad API key has an empty body. Check the HTTP status before parsing JSON.
I get 404 for an order I just created
Order endpoints take the Qasimha order id (installmentId from a session, id from a checkout link), not your orderReferenceId. To look up by your reference, use GET /api/v1/merchant/orders/by-reference/{orderReferenceId}. Also check that the key belongs to the same environment and merchant as the order.
I get 429 even though my traffic is low
All merchant endpoints share one budget per key. A polling loop on orders or checkout links can use it up. Switch to webhooks, or spread the work across a separate key per system.
Related
Updated about 23 hours ago
