Guides
Error handling
The Puntjes API uses standard HTTP status codes and returns structured error responses for all failures.
Error response format
All error responses follow this structure:
{
"error": {
"code": "ERROR_CODE",
"message": "A human-readable description of what went wrong.",
"status": 422,
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"details": {}
}
}
| Field | Description |
|---|---|
code | Machine-readable error code. Use this for programmatic handling |
message | Human-readable error message. Suitable for logging, not for end users |
status | HTTP status code (mirrored in the response status) |
request_id | Unique request identifier for debugging and support |
details | Additional context (present on validation errors) |
The same request_id comes back in the X-Request-ID response header, on successful responses too.
Every log line the request writes on our side carries it, so support can find the request from the id
alone. Send your own X-Request-ID (letters, digits, ., _ and -, at most 128 characters) and
Puntjes uses it instead of minting one.
HTTP status codes
| Status | Description |
|---|---|
200 | Success |
201 | Resource created |
202 | Accepted. The work runs on a queue and has not happened yet |
204 | Success, and the response has no body |
400 | The request could not be attributed to a vendor, or its JSON body cannot be read |
401 | Unauthorized. Invalid or expired token |
403 | Forbidden. Vendor suspended or deactivated |
404 | Resource not found |
405 | The path exists but not for this HTTP verb |
409 | Conflict. The resource is already registered |
415 | The body is of a type the API cannot read. Send JSON, or a form |
422 | Validation error or business rule violation |
429 | Rate limit or plan limit exceeded |
500 | Internal server error |
503 | A service the request needs is not ready yet. Retry after the seconds in the Retry-After header |
Error codes reference
Authentication errors
| Code | Status | Description |
|---|---|---|
INVALID_CLIENT | 401 | The token is valid, but its OAuth client belongs to no vendor or cannot use the client credentials grant. A new token does not help; check the client |
UNAUTHENTICATED | 401 | The access token is missing, unreadable, expired or revoked, or its client was revoked. Request a new token |
Authorization errors
| Code | Status | Description |
|---|---|---|
FORBIDDEN | 403 | The token is valid but not allowed to perform this action |
SUBSCRIPTION_EXPIRED | 403 | The subscription for this workspace has ended. Reads still answer 200; writes stop until the workspace is on a plan again. Waiting never clears this, which is why it is a 403 and not a 429. |
VENDOR_CONTEXT_MISSING | 500 | The token resolved to no vendor at all. Re-issue it from the vendor's own API client |
VENDOR_CONTEXT_REQUIRED | 400 | The endpoint needs a vendor context and the token carried none |
VENDOR_DEACTIVATED | 403 | Vendor account has been permanently deactivated |
VENDOR_INACTIVE | 403 | The vendor account is not in a state that accepts API traffic |
VENDOR_PENDING | 403 | The vendor account has not been activated yet |
VENDOR_SUSPENDED | 403 | Vendor account is suspended and grace period has expired |
Validation errors
| Code | Status | Description |
|---|---|---|
BATCH_TOO_LARGE | 422 | A batch may contain at most 100 products; split the catalogue and push it in chunks |
VALIDATION_ERROR | 422 | The request body or a query parameter failed validation. See details for field-level errors |
Customer registration errors
| Code | Status | Description |
|---|---|---|
CUSTOMER_ALREADY_LINKED | 409 | The customer already has a different external_id; linking never overwrites an existing one |
EXTERNAL_ID_DUPLICATE | 409 | The external_id is already registered for this vendor |
IDENTIFIER_DUPLICATE | 409 | The identifier value or the customer email is already registered for this vendor, or the scanned card was claimed by another customer first |
LOYALTY_CARD_NOT_FOUND | 422 | The submitted loyalty_card value is not a free card of this vendor: it is unknown, or the shop blocked it. Rescan, or omit identifiers and let Puntjes issue a card. |
MARKETING_CONSENT_UNDER_AGE | 422 | The customer is younger than 13 by their date_of_birth, so marketing_consent cannot be recorded for them |
Resource errors
| Code | Status | Description |
|---|---|---|
BRANCH_INACTIVE | 422 | The branch was deactivated. Raised for a new transaction, and for a credential whose default branch has since closed, never as a silent fall-back to no branch at all |
BRANCH_NOT_FOUND | 422 | No branch of yours has this branch key. Existence is checked against deactivated branches too, so this distinguishes a typo from a closed shop |
CUSTOMER_DEACTIVATED | 422 | The customer exists but is deactivated, so no points can be earned or spent |
CUSTOMER_EMAIL_SUPPRESSED | 422 | Mail to the customer's address bounced, at your shop or at another one, or your mail to it was marked as spam, so no loyalty card is sent to it until the address is corrected or sending is switched back on in the admin portal |
CUSTOMER_HAS_NO_EMAIL | 422 | The customer has no email address on file, so there is nowhere to send their loyalty card |
CUSTOMER_NOT_FOUND | 404 | No customer found with the given identifier or ID |
EXTERNAL_ID_NOT_FOUND | 404 | No customer of yours has this external_id. Soft-deleted and anonymized customers are unreachable; a deactivated one is found and answered with CUSTOMER_DEACTIVATED instead |
NO_WALLET | 422 | The customer has no wallet with this vendor yet, so there is no balance to spend from |
VOUCHER_NOT_FOUND | 404 | No voucher of yours carries this BON- code. A code belonging to another vendor returns this same 404. |
PRODUCT_EXTERNAL_ID_DUPLICATE | 409 | A product with this external_id already exists. Use PUT to create-or-update instead |
PRODUCT_NOT_FOUND | 404 | No product with this external_id belongs to your vendor |
REDEMPTION_NOT_FOUND | 404 | No redemption found with the given confirmation code |
REWARD_NOT_FOUND | 404 | No reward with this id belongs to your vendor |
REWARD_UNAVAILABLE | 422 | Reward is inactive, outside its availability window, or not found |
WALLET_NOT_FOUND | 404 | No wallet exists for this customer, so there is nothing to read or adjust |
Business rule errors
| Code | Status | Description |
|---|---|---|
BRANCH_REQUIRED | 422 | The reward or campaign bon may only be spent at some of your branches, and the request named none of them: either no branch at all, or one that is not on its list. Nothing is spent, so the same call succeeds once it names a branch that is allowed |
CODE_ALREADY_USED | 422 | Redemption was already verified |
CODE_CANCELLED | 422 | The shop cancelled the redemption and gave the points back, so the confirmation code no longer works |
CODE_EXPIRED | 422 | Confirmation code has expired |
IDEMPOTENCY_KEY_CONFLICT | 422 | The idempotency_key was already used for a different customer, amount or reward |
INSUFFICIENT_BALANCE | 422 | Customer does not have enough points for the redemption or wallet adjustment |
OUT_OF_STOCK | 422 | Reward has no remaining stock |
REDEMPTION_LIMIT_REACHED | 422 | The customer already redeemed this reward the most times the shop allows |
VERIFICATION_FAILED | 422 | The code could not be verified for a reason other than expiry or reuse |
VOUCHER_ALREADY_USED | 422 | The voucher was already spent. Verifying is what spends it, so a repeated call lands here. Branch on this before offering any goodwill discount. |
VOUCHER_EXPIRED | 422 | The voucher is past its valid_until date, which is inclusive. The last valid day is not "past" |
Rate limiting
| Code | Status | Description |
|---|---|---|
CARD_SEND_THROTTLED | 429 | The customer was sent their loyalty card moments ago. Unlike the other two 429s this one clears in seconds. details.retry_after says how many. |
PLAN_LIMIT_EXCEEDED | 429 | The transaction allowance on the vendor's billing plan is spent for the current period |
RATE_LIMITED | 429 | Too many requests in the current window. Retry after a short wait |
Request errors
| Code | Status | Description |
|---|---|---|
INVALID_JSON | 400 | The body is sent as JSON but is not valid JSON: it is cut off, or it is not valid UTF-8. Nothing was created, changed or sent. Fix the body before you send it again. POST /oauth/token answers a body like that with this code too |
METHOD_NOT_ALLOWED | 405 | The path exists but not for this HTTP verb |
ROUTE_NOT_FOUND | 404 | No endpoint exists at this path, or the path holds a NUL byte or a byte that is not valid UTF-8. Check the version prefix and the spelling |
UNSUPPORTED_MEDIA_TYPE | 415 | The request has a body, but its Content-Type is not JSON, application/x-www-form-urlencoded or multipart/form-data, so the API cannot read it. Nothing was created, changed or sent. Send the body as JSON with Content-Type: application/json. A request with no body is not affected. This answer comes before the token is checked |
Server errors
| Code | Status | Description |
|---|---|---|
APPLE_WALLET_UNAVAILABLE | 503 | The shop cannot sign Apple Wallet passes yet: Apple has not finished setting up its pass identity. Retry after the seconds in Retry-After; the google platform is not affected |
INTERNAL_ERROR | 500 | Something failed on our side. Retry, and quote request_id if it persists |
Returned by POST /api/v1/customers. See Customer endpoints for the full
request contract.
The identifiers array accepts two types
identifiers[].type accepts exactly email and loyalty_card. Any other value is rejected
with VALIDATION_ERROR (422), and the message names the accepted types so the fix is
self-service. If your client sends a type that is no longer accepted, update the client.
There is no compatibility fallback.
Reading is unaffected: GET /customers/lookup matches on the value alone, so every
identifier your customers already carry keeps resolving.
Four codes tell you whether a retry is worth attempting, and only three of them are 429s.
Branch on the code. The status alone stopped separating these cases when the fourth one
arrived on a different status class.
RATE_LIMITED clears on its own: its Retry-After header says how many seconds to wait
before you retry. CARD_SEND_THROTTLED also clears on its own, in seconds, and bounds one
customer's inbox rather than your throughput. details.retry_after says how many seconds,
and a Retry-After header carries the same number. PLAN_LIMIT_EXCEEDED does not clear by
waiting within the period: it means the plan's transaction allowance for the current period
is spent, and retrying only burns what is left. The next period refills it; a larger plan
raises it.
SUBSCRIPTION_EXPIRED is a 403, not a fourth 429. Writes stop when a workspace's
subscription ends and reads keep answering 200, so a GET never sees this code at all.
Waiting never clears it: the workspace is read-only until it is on a plan again, and a
person has to choose one. A vendor admin does that themselves under Billing; an operator
can also assign one on their behalf. It belongs to the same 403
state family as VENDOR_SUSPENDED and VENDOR_DEACTIVATED, with one difference worth
coding around: those two refuse reads as well.
Validation error details
When the error code is VALIDATION_ERROR, the details field contains per-field error messages:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The given data was invalid.",
"status": 422,
"request_id": "...",
"details": {
"email": ["validation.email"],
"identifiers.0.type": ["The identifier type must be one of: email, loyalty_card."]
}
}
}
Each key in details is the field name, and the value is an array of error messages for that field. A
built-in rule sends a key such as validation.email or validation.required, which your client can
translate. A rule of our own sends an English sentence. An email that another of your customers already
uses is not a validation error: it answers 409 IDENTIFIER_DUPLICATE.
A required field you send empty ("" or null) can get two keys: first the key of its type
(validation.string for text, validation.numeric for a number), then validation.required. A field you
leave out gets validation.required alone. So look for validation.required anywhere in the array, not
only as the first entry. In POST /products/batch the same keys arrive in the item's errors.
Query parameters and the fields of a form-encoded or multipart body go through the same validation. A text
that is not valid UTF-8 once the percent-encoding is decoded answers 422, and details names the field. So
GET /products?search=%FF answers 422 with details.search, because %FF decodes to one lone byte, which
is not a UTF-8 character. For a list such as metadata, details names the list. A JSON body with such a
byte is not valid JSON at all, so it answers 400 INVALID_JSON instead, and so does a JSON body that is cut
off. An empty body, {}, [] and null are valid and keep their answers. A body of any other type, such
as text/plain, answers 415 UNSUPPORTED_MEDIA_TYPE. The case of the type does not matter:
Application/JSON is read as JSON. A form-encoded or multipart body that holds none of the fields the
endpoint reads answers 422 VALIDATION_ERROR, and details names each key it did hold. The usual cause is
curl -d '{"first_name":"Jan"}': curl sends that as a form with one key, {"first_name":"Jan"}. Send JSON
with Content-Type: application/json instead. Both answers come before the token is checked, so you get them
even with no token.
Three values are checked only when the endpoint reads them: identifier on GET /customers/lookup when you
send no external_id, identifier on GET /rewards when affordable is on, and
marketing_consent_statement_version when marketing_consent is true. A field that fails another rule
first keeps only that rule's message. In POST /products/batch the item fails instead, with the field in its
errors, like any other bad item. The same bytes get the same answer every time. Fix the encoding in your
client instead of retrying.
A NUL byte inside any text, in the body or in the query string, answers 422 as well, with the field in
details: the database would keep the text only up to that byte. A NUL byte at the very start or end is
removed first, like a space. In POST /products/batch an item whose external_id holds one fails on its own.
A date filter such as date_from answers 422 to a year of five or more digits, such as 99999-01-01: PHP
would read it as another year and return the wrong rows.
Handling errors in your integration
async function submitTransaction(data) {
const response = await fetch('/api/v1/transactions', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
const { error } = await response.json();
switch (error.code) {
case 'CUSTOMER_NOT_FOUND':
// Prompt to register the customer
break;
case 'PLAN_LIMIT_EXCEEDED':
// Show limit warning, contact vendor admin
break;
case 'VALIDATION_ERROR':
// Handle field-level errors from error.details
break;
default:
// Log error.request_id for support
console.error(`API error: ${error.code}`, error.request_id);
}
}
return response.json();
}