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 |
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 |
422 | Validation error or business rule violation |
429 | Rate limit or plan limit exceeded |
500 | Internal server error |
Error codes reference
Authentication errors
| Code | Status | Description |
|---|---|---|
INVALID_CLIENT | 401 | The token is valid but its OAuth client is revoked or belongs to no vendor |
UNAUTHENTICATED | 401 | Missing, invalid, or expired access 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 | Request body 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 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 an unassigned card of this vendor. Rescan, or omit identifiers and let Puntjes issue a card. |
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_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_EXPIRED | 422 | Confirmation code has expired |
IDEMPOTENCY_KEY_CONFLICT | 422 | The idempotency_key was already used for a different customer 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 |
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 |
|---|---|---|
METHOD_NOT_ALLOWED | 405 | The path exists but not for this HTTP verb |
ROUTE_NOT_FOUND | 404 | No endpoint exists at this path. Check the version prefix and the spelling |
Server errors
| Code | Status | Description |
|---|---|---|
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: wait and 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": {
"identifiers.0.type": ["The identifier type must be one of: email, loyalty_card."],
"email": ["The email has already been taken."]
}
}
}
Each key in details is the field name, and the value is an array of error messages for that field.
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();
}