Errors
Every error looks the same:
{
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Not enough points on this card.",
"retryable": false,
"request_id": "req_34e6710244c0411790e74fc0f31548bf"
}
}
Branch on code, never on message. Codes are stable — we will never
rename one or change what it means. Messages are for humans and may be reworded
or translated.
Log request_id. It is the one thing that lets us find your exact request
if you need to ask us about it.
retryable is a promise, not a hint
When retryable is true, sending the identical request again — same
external_ref — cannot double-apply. Retry with backoff and stop worrying.
When it is false, retrying will fail the same way. Something has to change
first: a different card, a smaller amount, a corrected field.
The codes
| Code | Retry? | What happened | What the cashier should see | What your code should do |
|---|---|---|---|---|
VALIDATION_ERROR 400 |
no | The request was malformed. details names the fields. |
"Something went wrong — try again." | Fix the request. This is a bug on your side; log details loudly. |
UNAUTHENTICATED 401 |
no | The key is missing, wrong, revoked or expired. We do not say which. | "Terminal not set up." | Stop. Check the key. Do not retry in a loop. |
TENANT_SUSPENDED 403 |
no | The store's account is suspended, usually billing. | "Loyalty is paused — contact the manager." | Stop sending. The sale still completes; loyalty simply does not apply. |
CUSTOMER_NOT_FOUND 404 |
no | No customer matches that phone, email or card. | "Not a member yet — sign them up?" | Offer enrollment. Never treat this as an outage. |
COUPON_ALREADY_REDEEMED 409 |
no | That coupon has been used. | "This coupon has already been used." | Do not retry. |
ALREADY_ENROLLED 409 |
no | That phone or email is already a member. The existing customer is in the body. | "Already a member — here's their card." | Switch to their card and carry on. Do not ask the cashier to search again. |
RESERVATION_EXPIRED 410 |
no | The held balance timed out before you committed it. | "Basket expired — scan again." | Re-reserve, then commit. |
CARD_NOT_ELIGIBLE 422 |
no | This card cannot do this operation. | "Not available on this card." | Build buttons from allowed_operations and you will not hit this. |
INSUFFICIENT_BALANCE 422 |
no | Not enough points, stamps or credit. | "Not enough points." | Show the balance from identify. |
OTP_INVALID 422 |
no | The code was wrong, expired, or too many tries. details[0] says which. |
"That code is not valid." | Read details[0]: otp_incorrect, otp_expired, otp_attempts_exhausted, enrollment_not_found. |
TEMPLATE_MECHANICS_UNKNOWN 422 |
no | The card is configured in a way we cannot route. | "Not available on this card." | Nothing you can fix — tell us. This is our configuration problem. |
RATE_LIMITED 429 |
yes | Too many requests. | Nothing. | Back off for Retry-After seconds, then retry. |
INTERNAL 500 |
yes | Our bug. | "Something went wrong — try again." | Retry with backoff. Same external_ref. |
UPSTREAM_TIMEOUT 504 |
yes | Our loyalty provider was slow. | "Taking longer than usual." | Retry with the same external_ref. It cannot double-apply. |
DUPLICATE_TRANSACTION is not an error
You will see "duplicate": true at HTTP 200 with the original result. That is a
success: we recognised the receipt number and returned what we did the first
time. Treat it exactly like a first-time success.
The two mistakes worth naming
Generating a new external_ref when you retry. This is the only way to
double-award a customer, and no error code will tell you it happened — both
requests succeed. The receipt number identifies the sale, not the attempt.
Showing a button allowed_operations did not list. Every operation in that
list will work on that card. Anything not in it will be refused. If you build
your buttons from the list you will never see CARD_NOT_ELIGIBLE.