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

FieldTypeRequiredDescription
identifierstringYesCustomer identifier (card number, email, etc.)
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"
        },
        "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

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
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{"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

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
REWARD_NOT_FOUND404No reward found with the given reward_id
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

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

ParameterTypeDescription
codestringThe 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

CodeStatusDescription
CODE_ALREADY_USED422This redemption was already verified
CODE_EXPIRED422The confirmation code has expired
REDEMPTION_NOT_FOUND404No redemption found with this code
VERIFICATION_FAILED422The 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

ParameterTypeDescription
codestringThe voucher code the customer presents, BON-XXXXXXXX

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.

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

FieldTypeDescription
voucher_codestringThe code that was just spent
discountobject|nullPresent when kind is discount, null for a free-product bon
discount.kindstringpercentage or fixed
discount.percentageintegerPresent when kind is percentage. An integer from 1 to 100.
discount.amount_centsintegerPresent when kind is fixed. Integer cents. 750 is €7.50.
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.

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

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.
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.


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 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.