API reference
Redemption endpoints
Endpoints for redeeming rewards, 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
| Field | Type | Required | Description |
|---|---|---|---|
identifier | string | Yes | Customer identifier (card number, email, etc.) |
reward_id | integer | Yes | The reward to redeem |
idempotency_key | string | Yes | Unique key to prevent duplicate redemptions (max 255 characters) |
branch | string | No | Your 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"
},
"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"
}
}
}
Response fields
| Field | Description |
|---|---|
redemption_id | The redemption's ID. Use it to correlate with your own records |
confirmation_code | Unique code the customer presents at POS, in PNTJ-XXXXXXXX format |
points_deducted | Number of points removed from wallet |
remaining_balance | Wallet balance after redemption |
redeemed_at | When the redemption was created |
expires_at | When the confirmation code expires (if code_valid_for_hours is set on the reward), otherwise null |
type_specific_data | {"discount_value", "discount_type"} for discount rewards, {"product_reference"} for free-product rewards |
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.
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
| Code | Status | Description |
|---|---|---|
BRANCH_NOT_FOUND | 422 | No 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_REQUIRED | 422 | The 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_DEACTIVATED | 422 | Customer account is deactivated |
CUSTOMER_NOT_FOUND | 404 | No customer found with the given identifier |
IDEMPOTENCY_KEY_CONFLICT | 422 | This idempotency_key was already used for a different customer or reward |
INSUFFICIENT_BALANCE | 422 | Customer doesn't have enough points |
NO_WALLET | 422 | The customer has no wallet |
OUT_OF_STOCK | 422 | Reward has no remaining stock |
REWARD_NOT_FOUND | 404 | No reward found with the given reward_id |
REWARD_UNAVAILABLE | 422 | Reward 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
| Parameter | Type | Description |
|---|---|---|
code | string | The confirmation code |
Response
{
"data": {
"redemption_id": 12,
"confirmation_code": "PNTJ-K4MP7QRS",
"status": "valid",
"reward": {
"name": "Free Coffee",
"type": "free_product"
},
"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"
}
}
}
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.
Verify a redemption
POST /api/v1/redemptions/{code}/verify
Mark a redemption as verified, confirming the reward has been delivered to the customer.
Path parameters
| Parameter | Type | Description |
|---|---|---|
code | string | The confirmation code |
Response
{
"data": {
"redemption_id": 12,
"confirmation_code": "PNTJ-K4MP7QRS",
"status": "valid",
"reward": {
"name": "Free Coffee",
"type": "free_product"
},
"points_deducted": 500,
"verified_at": null,
"type_specific_data": {
"product_reference": "COFFEE-REG"
}
}
}
Errors
| Code | Status | Description |
|---|---|---|
CODE_ALREADY_USED | 422 | This redemption was already verified |
CODE_EXPIRED | 422 | The confirmation code has expired |
REDEMPTION_NOT_FOUND | 404 | No redemption found with this code |
VERIFICATION_FAILED | 422 | The redemption could not be verified |
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 the exact shape 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, and there is no read-only lookup to check it
first. Calling twice returns VOUCHER_ALREADY_USED. Call it at the moment you are ready to hand the gift over, and
read what the customer is owed off the response rather than fetching it beforehand.
Path parameters
| Parameter | Type | Description |
|---|---|---|
code | string | The voucher code the customer presents, BON-XXXXXXXX |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
branch | string | No | Your 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. |
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.
A fixed-amount voucher carries a different key inside discount:
{ "kind": "fixed", "amount_cents": 750 }
A free-product voucher carries no discount at all. Its products is the list the cashier
hands over, snapshotted when the bon was issued:
{
"voucher_code": "BON-G1F2T3P4",
"discount": null,
"valid_until": null,
"consumed_at": "2026-08-12T10:14:02+00:00",
"campaign_id": 7,
"kind": "free_product",
"products": [
{ "id": 41, "name": "Croissant", "quantity": 2 },
{ "id": 58, "name": "Koffie", "quantity": 1 }
]
}
Response fields
| Field | Type | Description |
|---|---|---|
voucher_code | string | The code that was just spent |
discount | object|null | Present when kind is discount, null for a free-product bon |
discount.kind | string | percentage or fixed |
discount.percentage | integer | Present when kind is percentage. An integer from 1 to 100. |
discount.amount_cents | integer | Present when kind is fixed. Integer cents. 750 is €7.50. |
valid_until | string|null | The last date the voucher is good for, or null when it never expires |
consumed_at | string | ISO 8601 timestamp of this call |
campaign_id | integer | The campaign whose gift issued the voucher |
kind | string | Always present: discount or free_product |
products | array|null | Present when kind is free_product, null otherwise |
products[].id | integer|null | The product in your catalogue, or null for an entry that never had one |
products[].name | string | The product's name as the gift promised it |
products[].quantity | integer | How many of that product to hand over. Render it however you like. "2× Croissant" is what the portal shows. |
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.
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
| Code | Status | Description |
|---|---|---|
BRANCH_NOT_FOUND | 422 | No 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_REQUIRED | 422 | The 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. |
VOUCHER_ALREADY_USED | 422 | The voucher was already spent |
VOUCHER_EXPIRED | 422 | The voucher's validity has run out |
VOUCHER_NOT_FOUND | 404 | No 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.
Typical POS flow
- Customer presents their loyalty card at checkout
- POS calls
GET /customers/lookupto display their balance - Customer chooses a reward from
GET /rewards - POS calls
POST /redemptionsto redeem the reward - Customer receives a confirmation code (printed receipt, SMS, or displayed on screen)
- Later, when the customer presents the code, POS calls
POST /redemptions/{code}/verify
A customer arriving with a BON- code from a campaign email is a separate, shorter path:
call POST /vouchers/{code}/verify once, read kind, and either apply the discount or hand
over the products it lists.