API reference

Redemption endpoints

Endpoints for redeeming rewards, listing a customer's redemptions, looking up redemption details, and verifying confirmation codes and campaign gift vouchers at the point of sale.


Redeem a reward

POST /api/v1/redemptions

Deduct points from a customer's wallet and issue a confirmation code for a reward.

Request body

FieldTypeRequiredDescription
identifierstringYesCustomer identifier (card number, email, etc.), at most 255 characters
reward_idintegerYesThe reward to redeem
idempotency_keystringYesUnique key to prevent duplicate redemptions (max 255 characters)
branchstringNoYour own key for the branch this reward was handed over at: the external_id you gave it, at most 64 characters. It overrides the branch the API credential defaults to, so one shared credential can name a different shop per request. Omit it to use the credential's default, and omit both to leave the redemption unattributed. A reward that is limited to branches has to be given one of its own.

Example request

{
    "identifier": "CARD-001",
    "reward_id": 5,
    "idempotency_key": "redeem-2024-0042",
    "branch": "shop-1"
}
curl -X POST https://puntjes.app/api/v1/redemptions \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"identifier":"CARD-001","reward_id":5,"idempotency_key":"redeem-2024-0042","branch":"shop-1"}'

Response (201 Created)

{
    "data": {
        "redemption_id": 12,
        "confirmation_code": "PNTJ-K4MP7QRS",
        "reward": {
            "name": "Free Coffee",
            "type": "free_product",
            "id": 5
        },
        "points_deducted": 500,
        "remaining_balance": 1000,
        "redeemed_at": "2024-03-15T12:30:00+00:00",
        "expires_at": "2024-03-15T14:30:00+00:00",
        "type_specific_data": {
            "product_reference": "COFFEE-REG",
            "payment_amount": 200
        },
        "branches": [
            {
                "external_id": "shop-1",
                "name": "Gent",
                "type": "physical"
            }
        ]
    }
}

Response fields

FieldDescription
redemption_idThe redemption's ID. Use it to correlate with your own records
confirmation_codeUnique code the customer presents at POS, in PNTJ-XXXXXXXX format
rewardThe reward that was redeemed: {"name", "type", "id"}. id is the id that GET /rewards lists for this reward
points_deductedNumber of points removed from wallet
remaining_balanceWallet balance after redemption
redeemed_atWhen the redemption was created
expires_atWhen the confirmation code expires (if code_valid_for_hours is set on the reward), otherwise null
type_specific_data{"type", "discount_value", "discount_type", "product_reference"} for discount rewards, {"type", "product_reference", "payment_amount"} for free-product rewards. type is discount or free_product: read it to choose the shape
branchesThe branches the reward may be redeemed at, each {"external_id", "name", "type"}. null means every branch

For a discount, product_reference is the item number of the one product the discount is for: the external_id that product has in your catalogue. It is null when the discount counts on the whole purchase, and on every discount redeemed before the field existed. A fixed amount can be more than that product's price: your till decides how to cap it. In the answer, with the other fields left out:

{
    "data": {
        "type_specific_data": {
            "type": "discount",
            "discount_value": 20,
            "discount_type": "percentage",
            "product_reference": "THERMO-01"
        }
    }
}

payment_amount is what your till collects on top of the points, in cents (0 = points only). Puntjes does not take that payment. type_specific_data is copied when the reward is redeemed: an edit of the reward afterwards does not change what the lookup, the customer's list and the verify answer.

The reward's branch rule applies when the reward is redeemed, with branch on POST /redemptions. It applies again when the code is verified: see POST /redemptions/{code}/verify. branches shows the reward's branches as they are now, not as they were when it was redeemed. null means every branch. GET /redemptions/{code}, the list of a customer's redemptions and the verify answer carry reward.id and branches too.

Idempotency

The idempotency_key must be unique per vendor. If you redeem with an idempotency_key that already exists:

  • The original redemption is returned, with the same confirmation_code
  • Points are not deducted a second time
  • No new ledger entry is created and reward stock is not decremented again

This makes the call safe to retry after a network failure. A replay is answered even when the wallet can no longer afford the reward. The original redemption already paid for it, so a retry never fails with INSUFFICIENT_BALANCE. remaining_balance reflects the wallet's balance now, not at the time of the original redemption. A replay returns the original redemption also when it was cancelled later. Look the code up with GET /redemptions/{code} for its current status.

Choose idempotency keys carefully

Use a value that uniquely identifies the redemption in your system, such as an order ID or a UUID generated once per checkout. Avoid random values that change on retry: that defeats the point of idempotency. Reusing one key for a different customer or a different reward is rejected with IDEMPOTENCY_KEY_CONFLICT.

Errors

CodeStatusDescription
BRANCH_NOT_FOUND422No branch of yours has this key. Deactivated branches count as found. Redeeming does not ask whether a shop is still open, only which shop it was.
BRANCH_REQUIRED422The reward may only be redeemed at some of your branches, and this request named none of them: either no branch at all, or one the reward does not list. Send a branch the reward allows.
CUSTOMER_DEACTIVATED422Customer account is deactivated
CUSTOMER_NOT_FOUND404No customer found with the given identifier
IDEMPOTENCY_KEY_CONFLICT422This idempotency_key was already used for a different customer or reward
INSUFFICIENT_BALANCE422Customer doesn't have enough points
NO_WALLET422The customer has no wallet
OUT_OF_STOCK422Reward has no remaining stock
REDEMPTION_LIMIT_REACHED422The customer already redeemed this reward the most times the shop allows
REWARD_NOT_FOUND404No reward found with the given reward_id, including one the vendor withdrew
REWARD_UNAVAILABLE422Reward is inactive or outside availability window

Look up a redemption

GET /api/v1/redemptions/{code}

Find a redemption by its confirmation code. Use this to display redemption details before verifying.

Path parameters

ParameterTypeDescription
codestringThe confirmation code. Upper or lower case both work, and spaces around it are ignored

Response

{
    "data": {
        "redemption_id": 12,
        "confirmation_code": "PNTJ-K4MP7QRS",
        "status": "valid",
        "reward": {
            "name": "Free Coffee",
            "type": "free_product",
            "id": 5
        },
        "customer": {
            "name": "Jan De Vries"
        },
        "points_deducted": 500,
        "redeemed_at": "2024-03-15T12:30:00+00:00",
        "verified_at": null,
        "expires_at": "2024-03-15T14:30:00+00:00",
        "type_specific_data": {
            "product_reference": "COFFEE-REG",
            "payment_amount": 200
        },
        "branches": [
            {
                "external_id": "shop-1",
                "name": "Gent",
                "type": "physical"
            }
        ]
    }
}

status is valid until the code is verified, then used. A code past its expires_at is flipped to expired when it is looked up. A redemption the shop cancelled in the admin portal is cancelled, and stays cancelled after its expires_at. A reward the shop gave at the counter in the admin portal is used from the start.


List a customer's redemptions

GET /api/v1/customers/{customer}/redemptions

Every reward one customer redeemed at your shop, newest first, 15 per page. Use it when the customer is at the till without their PNTJ- code: find the customer by card with GET /customers/lookup, ask this list for status=valid, and verify the reward the customer picks with POST /redemptions/{code}/verify.

Path parameters

ParameterTypeDescription
customerstringThe customer ID, as GET /customers/lookup returns it

Query parameters

ParameterTypeRequiredDefaultDescription
statusstringNo—One of valid, used, expired or cancelled. Only a valid code past its expires_at counts as expired; a used or cancelled code keeps its status
pageintegerNo1Which page to return. A value below 1, or one that is not a whole number, returns page 1

Response

{
    "data": {
        "data": [
            {
                "redemption_id": 12,
                "confirmation_code": "PNTJ-K4MP7QRS",
                "status": "valid",
                "reward": {
                    "name": "Free Coffee",
                    "type": "free_product",
                    "id": 5
                },
                "customer": {
                    "name": "Jan De Vries"
                },
                "points_deducted": 500,
                "redeemed_at": "2024-03-15T12:30:00+00:00",
                "verified_at": null,
                "expires_at": "2024-03-15T14:30:00+00:00",
                "type_specific_data": {
                    "product_reference": "COFFEE-REG",
                    "payment_amount": 200
                },
                "branches": [
                    {
                        "external_id": "shop-1",
                        "name": "Gent",
                        "type": "physical"
                    }
                ]
            }
        ],
        "links": {
            "first": "string",
            "last": "string",
            "prev": "string",
            "next": "string"
        },
        "meta": {
            "current_page": 1,
            "last_page": 2,
            "per_page": 15,
            "total": 20
        }
    }
}

Each item has the same fields as GET /redemptions/{code}, so one parser reads both.

status is the status at the moment you ask. A code past its expires_at reports expired and is left out of status=valid, also when nobody has looked it up yet. A reward the shop gave at the counter in the admin portal is used, and a redemption the shop cancelled is cancelled, so neither is in status=valid.

Errors

CodeStatusDescription
CUSTOMER_NOT_FOUND404No customer of yours has this ID

Verify a redemption

POST /api/v1/redemptions/{code}/verify

Mark a redemption as verified, confirming the reward has been delivered to the customer.

A code is verified once. A reward the shop gave at the counter in the admin portal is already used, so its code answers CODE_ALREADY_USED. A redemption the shop cancelled answers CODE_CANCELLED: the customer has their points back, so do not hand over the reward.

Path parameters

ParameterTypeDescription
codestringThe confirmation code. Upper or lower case both work, and spaces around it are ignored

Request body

FieldTypeRequiredDescription
branchstringNoYour own key for the branch where the reward is handed over: the external_id you gave it, at most 64 characters. It overrides the branch your API credential defaults to. A reward that has branches has to be given one of them, whichever way it arrives.
idempotency_keystringNoYour own key for this verify, at most 255 characters. Send the same key again when an answer gets lost: the repeat answers the first success. A key verifies one code: on another code it answers IDEMPOTENCY_KEY_CONFLICT

The body is optional. A reward with no branches verifies with no body at all. A body that cannot be read answers 400 INVALID_JSON, and a wrong value answers 422 VALIDATION_ERROR.

Idempotency

Send an idempotency_key with every verify. When the answer gets lost, send the same call again with the same key. The repeat answers the first success, with the same verified_at. Without the key, a second call answers CODE_ALREADY_USED. A key verifies one code. Sent with another code, it answers IDEMPOTENCY_KEY_CONFLICT, and that code stays valid. The key of a verify is separate from the idempotency_key of the redemption, so you can use one value for both.

Branch

A reward with branches verifies only at one of them. The branch is resolved like on POST /redemptions: the branch you send, else the branch your API credential defaults to, else none. With none, or with a branch the reward does not list, the call answers BRANCH_REQUIRED, and the code stays valid. A reward whose branches is null verifies at any branch. A verify of a limited reward with no branch known succeeded before this rule. It answers BRANCH_REQUIRED now. Three more calls that succeeded before now answer 422. A branch that names no branch, on a reward with no branches, answers BRANCH_NOT_FOUND. An idempotency_key over 255 characters answers VALIDATION_ERROR. One idempotency_key sent with two codes answers IDEMPOTENCY_KEY_CONFLICT on the second code. branches is the list the reward has now. A repeat with the key that verified the code answers before the branch rule is applied, also after the reward's branches changed. A branch key that names no branch of yours still answers BRANCH_NOT_FOUND, also on a repeat.

Example request

{
    "branch": "shop-1",
    "idempotency_key": "verify-2024-0042"
}
curl -X POST https://puntjes.app/api/v1/redemptions/1/verify \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"branch":"shop-1","idempotency_key":"verify-2024-0042"}'

Response

{
    "data": {
        "redemption_id": 12,
        "confirmation_code": "PNTJ-K4MP7QRS",
        "status": "used",
        "reward": {
            "name": "Free Coffee",
            "type": "free_product",
            "id": 5
        },
        "points_deducted": 500,
        "verified_at": "2024-03-15T12:45:00+00:00",
        "type_specific_data": {
            "product_reference": "COFFEE-REG",
            "payment_amount": 200
        },
        "branches": [
            {
                "external_id": "shop-1",
                "name": "Gent",
                "type": "physical"
            }
        ]
    }
}

Errors

CodeStatusDescription
BRANCH_NOT_FOUND422No branch of yours has this key. Deactivated branches count as found. Verifying does not ask whether a shop is still open, only which shop it was.
BRANCH_REQUIRED422The reward may only be verified at some of your branches, and this request named none of them: either no branch at all, or one the reward does not list. The code stays valid; send a branch the reward allows and call again.
CODE_ALREADY_USED422This redemption was already verified
CODE_CANCELLED422The shop cancelled this redemption in the admin portal and gave the points back, so the code no longer works
CODE_EXPIRED422The confirmation code has expired
IDEMPOTENCY_KEY_CONFLICT422This idempotency_key already verified a different code. The code in this call is not verified; send a new key
REDEMPTION_NOT_FOUND404No redemption found with this code
VERIFICATION_FAILED422The redemption could not be verified

Look up a voucher

GET /api/v1/vouchers/{code}

Read what a campaign bon gives without spending it. A till that scans a BON- code can show the cashier the discount or the products, check that they are on the open sale, and only then call POST /vouchers/{code}/verify. Nothing changes when you call this: the bon stays as it is.

The answer carries the same fields as the verify answer, plus status. A bon that was spent and has since run out of validity reads used, the same fact verify reports.

Path parameters

ParameterTypeDescription
codestringThe voucher code the customer presents, BON-XXXXXXXX. Upper or lower case both work, and spaces around it are ignored

Response

{
    "data": {
        "voucher_code": "BON-7K2MQX4P",
        "discount": null,
        "valid_until": "2026-08-31",
        "consumed_at": "2026-08-12T10:14:02+00:00",
        "campaign_id": 7,
        "kind": "free_product",
        "products": [
            {
                "id": 41,
                "name": "Croissant",
                "quantity": 2,
                "product_reference": "CROISSANT-01"
            }
        ],
        "status": "valid"
    }
}

Errors

CodeStatusDescription
VOUCHER_NOT_FOUND404No voucher of yours carries this code

A code of another vendor answers the same 404 as a code that was never issued, byte for byte, for the reason the verify section below gives.


Verify a voucher

POST /api/v1/vouchers/{code}/verify

Vouchers

A bon is what a campaign gift hands to one customer: the birthday mail that arrives with 15% off, or the one that arrives promising two croissants and a coffee. It is not a reward and costs no points. The customer never chose it and never paid for it, so there is nothing to redeem and no wallet to deduct from. What there is, is a code to turn in.

kind says which of the two you are holding. A discount bon carries a discount object your till applies; a free_product bon carries products, the items to hand over. One code covers the whole gift however many products it names, so a two-item gift is one scan and one call.

Voucher codes are BON- followed by eight characters, which keeps them distinguishable from the PNTJ- confirmation codes above at the counter and in a support call.

Branch on `kind`, not on which field is set

Every bon issued before free-product bons existed is a discount one, and discount keeps every key it always had. A client that reads discount without checking kind gets null the first time a vendor gifts a product. Read kind first and treat anything you do not recognise as a discount.

This call spends the voucher

Verifying is not previewing. A successful call marks the voucher used. To see what a bon gives before you spend it, call GET /vouchers/{code}. Send an idempotency_key with every verify: when the answer gets lost, the same call with the same key answers the first success again. Without that key, a second call returns VOUCHER_ALREADY_USED.

Path parameters

ParameterTypeDescription
codestringThe voucher code the customer presents, BON-XXXXXXXX. Upper or lower case both work, and spaces around it are ignored

Request body

FieldTypeRequiredDescription
branchstringNoYour own key for the branch the bon is being spent at: the external_id you gave it, at most 64 characters. It overrides the branch your API credential defaults to. Omit both for a bon that names no branches; a bon that names some has to be given one of them, whichever way it arrives.
idempotency_keystringNoYour own key for this spend, at most 255 characters. Send the same key again when an answer gets lost: the repeat answers the first success. A key is used once: on another bon it answers IDEMPOTENCY_KEY_CONFLICT

The body is optional. A bon from a campaign that runs everywhere spends with no body at all:

curl -X POST https://puntjes.app/api/v1/vouchers/BON-7K2MQX4P/verify \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

A bon from a campaign limited to some of your branches has to name one of them, and the branch is resolved the same way POST /transactions and POST /redemptions resolve theirs: the branch you send, then the branch your API credential defaults to, then nothing. A till whose credential already names its shop therefore needs no body here either.

curl -X POST https://puntjes.app/api/v1/vouchers/BON-7K2MQX4P/verify \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"branch": "shop-1"}'

The branches a bon allows are fixed when the campaign issues it, not read from the campaign at the till. Rescoping the campaign changes what the next bon allows and never what an outstanding one does. The rule products and valid_until already follow, and the reason the branch printed in the customer's email is the branch the till accepts.

Response

{
    "data": {
        "voucher_code": "BON-7K2MQX4P",
        "discount": null,
        "valid_until": "2026-08-31",
        "consumed_at": "2026-08-12T10:14:02+00:00",
        "campaign_id": 7,
        "kind": "free_product",
        "products": [
            {
                "id": 41,
                "name": "Croissant",
                "quantity": 2,
                "product_reference": "CROISSANT-01"
            }
        ]
    }
}

That is a free-product bon. A discount bon is the other shape: discount is set and products is null.

{
    "data": {
        "voucher_code": "BON-7K2MQX4P",
        "discount": { "type": "percentage", "kind": "percentage", "percentage": 15, "product_reference": null },
        "valid_until": "2026-08-31",
        "consumed_at": "2026-08-12T10:14:02+00:00",
        "campaign_id": 7,
        "kind": "discount",
        "products": null
    }
}

A fixed-amount bon carries a different key inside data.discount. This one is for one product:

{ "type": "fixed", "kind": "fixed", "amount_cents": 750, "product_reference": "THERMO-01" }

Response fields

FieldTypeDescription
voucher_codestringThe code that was just spent
discountobject|nullPresent when kind is discount, null for a free-product bon
discount.typestringpercentage or fixed
discount.kindstringDeprecated: the same value as discount.type. Read type.
discount.percentageintegerPresent when discount.type is percentage. An integer from 1 to 100.
discount.amount_centsintegerPresent when discount.type is fixed. Integer cents. 750 is €7.50.
discount.product_referencestring|nullThe item number of the one product the discount is for; null when it counts on the whole purchase
valid_untilstring|nullThe last date the voucher is good for, or null when it never expires
consumed_atstringISO 8601 timestamp of this call
campaign_idintegerThe campaign whose gift issued the voucher
kindstringAlways present: discount or free_product
productsarray|nullPresent when kind is free_product, null otherwise
products[].idinteger|nullThe product in your catalogue, or null for an entry that never had one
products[].namestringThe product's name as the gift promised it
products[].quantityintegerHow many of that product to hand over. Render it however you like. "2× Croissant" is what the portal shows.
products[].product_referencestring|nullThe item number of that product, so your till can put it on the sale; null for an entry with no catalogue product

The amount lives under a key named after its kind rather than under one shared value. With a shared key, a client reading a fixed voucher as a percentage turns €7.50 off into 750% off; with two keys it is a missing-key error in your code instead.

discount.product_reference is the external_id of that product in your catalogue, copied when the bon was issued. A fixed amount can be more than the product's price: your till decides how to cap it. Every bon issued before the field existed answers null.

products[].product_reference is the external_id of each product, copied when the bon was issued, the same as discount.product_reference. Every bon issued before the field existed answers null.

products is a copy taken when the bon was issued, not a live read of the campaign. Editing the gift afterwards changes what the next bon promises and never what this one redeems. The same rule valid_until follows. Free-product bons carry valid_until: null today: the validity window is a kortingsbon control, and a gift configured without one never expires.

Errors

CodeStatusDescription
BRANCH_NOT_FOUND422No branch of yours has this key. Deactivated branches count as found. Spending a bon does not ask whether a shop is still open, only which shop it was.
BRANCH_REQUIRED422The bon may only be spent at some of your branches, and this request named none of them: either no branch at all, or one the bon does not list. The bon is not spent; send a branch it allows and call again.
IDEMPOTENCY_KEY_CONFLICT422This idempotency_key already spent a different bon. The bon in this call is not spent; send a new key
VOUCHER_ALREADY_USED422The voucher was already spent
VOUCHER_EXPIRED422The voucher's validity has run out
VOUCHER_NOT_FOUND404No voucher of yours carries this code

A code belonging to a different vendor returns the same 404 as a code that was never issued: the same status and the same body, byte for byte. Anything that told the two apart would let a client walk the code space and learn which codes are real without ever being able to spend one.

A voucher that was spent last month and has since run out of validity reports VOUCHER_ALREADY_USED, not VOUCHER_EXPIRED. That is the fact the cashier needs: "expired" reads as never redeemed, and invites a goodwill discount on top of the one already given.

Expiry runs to the end of the day named

valid_until is a date, not an instant, and the voucher is still good on that date. The customer's email promises "geldig tot 30 september" and turning up on the 30th is within their rights. The date is fixed when the voucher is issued, so shortening the campaign's validity window afterwards cannot shorten a voucher already sitting in somebody's inbox. A gift configured without a window mints a voucher with valid_until: null, which never expires.

Puntjes does not move the money

Verifying writes one timestamp and nothing else: no transaction, no ledger entry, no points in either direction. Applying the discount is your till's job, and this endpoint is the record of what was promised and whether it was turned in.


List a customer's open vouchers

GET /api/v1/customers/{customer}/vouchers

Every campaign bon one customer can still spend with you, in one list with no pages. Use it when the customer is at the till without their BON- code: find the customer by card with GET /customers/lookup, ask this list, and spend the bon the customer picks with POST /vouchers/{code}/verify.

Path parameters

ParameterTypeDescription
customerstringThe customer ID, as GET /customers/lookup returns it

Response

{
    "data": [
        {
            "voucher_code": "BON-7K2MQX4P",
            "campaign_id": 7,
            "kind": "free_product",
            "discount": null,
            "products": [
                {
                    "id": 41,
                    "name": "Croissant",
                    "quantity": 2,
                    "product_reference": "CROISSANT-01"
                }
            ],
            "valid_until": "2026-08-31",
            "branches": [
                {
                    "external_id": "shop-1",
                    "name": "Gent",
                    "type": "physical"
                }
            ]
        }
    ]
}

The bon that runs out first comes first. A bon with no last day comes last. A bon that was spent, or whose valid_until has passed, is not in the list. A bon stays in the list for the whole of its last day, Belgian time, which is also when verify still accepts it.

kind, discount and products are the same as in GET /vouchers/{code}, so one parser reads both. branches names the branches that take the bon, and is null for a bon every branch takes. A till at another branch can still show the bon and tell the customer where to spend it, because verify there answers BRANCH_REQUIRED.

A deactivated customer still gets their list, because their bons can still be spent.

Errors

CodeStatusDescription
CUSTOMER_NOT_FOUND404No customer of yours has this ID

Typical POS flow

  1. Customer presents their loyalty card at checkout
  2. POS calls GET /customers/lookup to display their balance
  3. Customer chooses a reward from GET /rewards
  4. POS calls POST /redemptions to redeem the reward
  5. Customer receives a confirmation code (printed receipt, SMS, or displayed on screen)
  6. Later, when the customer presents the code, POS calls POST /redemptions/{code}/verify

A customer who comes back without the code can still collect: after step 2, POS calls GET /customers/{customer}/redemptions?status=valid, the cashier picks the reward from that list, and POS verifies its confirmation_code.

A customer arriving with a BON- code from a campaign email is a separate, shorter path: call GET /vouchers/{code} to see what the bon gives, read kind, check the discount or the products against the open sale, then call POST /vouchers/{code}/verify with an idempotency_key and apply the discount or hand over the products.

A customer with a campaign gift but without the BON- code takes the same path after one more call: after step 2, POS calls GET /customers/{customer}/vouchers and the cashier picks the bon from that list.