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": {}
    }
}
FieldDescription
codeMachine-readable error code. Use this for programmatic handling
messageHuman-readable error message. Suitable for logging, not for end users
statusHTTP status code (mirrored in the response status)
request_idUnique request identifier for debugging and support
detailsAdditional 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

StatusDescription
200Success
201Resource created
202Accepted. The work runs on a queue and has not happened yet
204Success, and the response has no body
400The request could not be attributed to a vendor
401Unauthorized. Invalid or expired token
403Forbidden. Vendor suspended or deactivated
404Resource not found
405The path exists but not for this HTTP verb
409Conflict. The resource is already registered
422Validation error or business rule violation
429Rate limit or plan limit exceeded
500Internal server error

Error codes reference

Authentication errors

CodeStatusDescription
INVALID_CLIENT401The token is valid but its OAuth client is revoked or belongs to no vendor
UNAUTHENTICATED401Missing, invalid, or expired access token

Authorization errors

CodeStatusDescription
FORBIDDEN403The token is valid but not allowed to perform this action
SUBSCRIPTION_EXPIRED403The 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_MISSING500The token resolved to no vendor at all. Re-issue it from the vendor's own API client
VENDOR_CONTEXT_REQUIRED400The endpoint needs a vendor context and the token carried none
VENDOR_DEACTIVATED403Vendor account has been permanently deactivated
VENDOR_INACTIVE403The vendor account is not in a state that accepts API traffic
VENDOR_PENDING403The vendor account has not been activated yet
VENDOR_SUSPENDED403Vendor account is suspended and grace period has expired

Validation errors

CodeStatusDescription
BATCH_TOO_LARGE422A batch may contain at most 100 products; split the catalogue and push it in chunks
VALIDATION_ERROR422Request body failed validation. See details for field-level errors

Customer registration errors

CodeStatusDescription
CUSTOMER_ALREADY_LINKED409The customer already has a different external_id; linking never overwrites an existing one
EXTERNAL_ID_DUPLICATE409The external_id is already registered for this vendor
IDENTIFIER_DUPLICATE409The identifier value is already registered for this vendor, or the scanned card was claimed by another customer first
LOYALTY_CARD_NOT_FOUND422The submitted loyalty_card value is not an unassigned card of this vendor. Rescan, or omit identifiers and let Puntjes issue a card.

Resource errors

CodeStatusDescription
BRANCH_INACTIVE422The 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_FOUND422No branch of yours has this branch key. Existence is checked against deactivated branches too, so this distinguishes a typo from a closed shop
CUSTOMER_DEACTIVATED422The customer exists but is deactivated, so no points can be earned or spent
CUSTOMER_HAS_NO_EMAIL422The customer has no email address on file, so there is nowhere to send their loyalty card
CUSTOMER_NOT_FOUND404No customer found with the given identifier or ID
EXTERNAL_ID_NOT_FOUND404No 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_WALLET422The customer has no wallet with this vendor yet, so there is no balance to spend from
VOUCHER_NOT_FOUND404No voucher of yours carries this BON- code. A code belonging to another vendor returns this same 404.
PRODUCT_EXTERNAL_ID_DUPLICATE409A product with this external_id already exists. Use PUT to create-or-update instead
PRODUCT_NOT_FOUND404No product with this external_id belongs to your vendor
REDEMPTION_NOT_FOUND404No redemption found with the given confirmation code
REWARD_NOT_FOUND404No reward with this id belongs to your vendor
REWARD_UNAVAILABLE422Reward is inactive, outside its availability window, or not found
WALLET_NOT_FOUND404No wallet exists for this customer, so there is nothing to read or adjust

Business rule errors

CodeStatusDescription
BRANCH_REQUIRED422The 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_USED422Redemption was already verified
CODE_EXPIRED422Confirmation code has expired
IDEMPOTENCY_KEY_CONFLICT422The idempotency_key was already used for a different customer or reward
INSUFFICIENT_BALANCE422Customer does not have enough points for the redemption or wallet adjustment
OUT_OF_STOCK422Reward has no remaining stock
VERIFICATION_FAILED422The code could not be verified for a reason other than expiry or reuse
VOUCHER_ALREADY_USED422The voucher was already spent. Verifying is what spends it, so a repeated call lands here. Branch on this before offering any goodwill discount.
VOUCHER_EXPIRED422The voucher is past its valid_until date, which is inclusive. The last valid day is not "past"

Rate limiting

CodeStatusDescription
CARD_SEND_THROTTLED429The 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_EXCEEDED429The transaction allowance on the vendor's billing plan is spent for the current period
RATE_LIMITED429Too many requests in the current window. Retry after a short wait

Request errors

CodeStatusDescription
METHOD_NOT_ALLOWED405The path exists but not for this HTTP verb
ROUTE_NOT_FOUND404No endpoint exists at this path. Check the version prefix and the spelling

Server errors

CodeStatusDescription
INTERNAL_ERROR500Something 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();
}