Core concepts
Rewards & Redemptions
Rewards are items that customers can purchase with their accumulated loyalty points. When a customer spends points on a reward, a redemption is created with a unique confirmation code.
Reward types
GET /rewards tells the two types apart by type, and gives each reward's payment_amount, product_reference, discount_type and discount_value. So before it redeems a reward, a till can check that the product of a discount is on the sale, and whether the sale allows a percentage or only a fixed amount. The till gets every field below in type_specific_data when it redeems the reward with POST /redemptions, and settles the reward with those, not with the list: the redemption keeps the values it had at that moment (see Redemption endpoints).
Discount
Money off a future purchase, either off the whole purchase or off one product:
| Field | Description |
|---|---|
discount_value | How much comes off. For fixed_amount, an amount in euro cents (500 is EUR 5.00; the smallest is 1, EUR 0.01). For percentage, a whole number from 1 to 100 |
discount_type | fixed_amount (e.g., EUR 5.00 off) or percentage (e.g., 10% off) |
product_reference | The item number (external_id) of the one product the discount is for. null when it counts on the whole purchase |
A fixed amount can be more than the product's price. Your till decides how to cap it.
Free product
A complimentary product or service:
| Field | Description |
|---|---|
product_reference | Reference to the free product (vendor-defined) |
payment_amount | What the till collects on top of the points, in euro cents (200 is EUR 2.00). 0 means points only |
The till collects that amount from the customer; Puntjes does not record the payment. Read the amount from the redemption, not from GET /rewards: the redemption keeps the amount that applied when the customer redeemed, even when the reward changes afterwards.
Reward properties
What GET /rewards returns for each reward:
| Field | Type | Description |
|---|---|---|
id | integer | Unique reward ID |
name | string | Reward name |
description | string|null | Reward description, or null when there is none |
type | string | discount or free_product |
point_cost | integer | Number of points required to redeem |
payment_amount | integer | What the till collects on top of the points, in euro cents. 0 means points only, and a discount is always 0 |
image_url | string|null | Absolute URL to the reward image, or null when the reward has none |
remaining_stock | integer | Redemptions still available. 0 for a reward with no limit: read is_unlimited first |
total_stock | integer|null | Redemptions the reward started with. null means unlimited |
available_from | string|null | Start of availability window, a date as YYYY-MM-DD. null means no start date |
available_until | string|null | End of availability window, a date as YYYY-MM-DD. null means the reward does not expire |
branches | array|null | The branches this reward may be redeemed at, each { "external_id", "name", "type" }. null means every branch |
product_reference | string|null | The item number of the product the reward is about. For a discount, the one product it comes off; null means the whole purchase. For a free_product, the product to hand over; null means the shop gave no item number. Read type first |
is_unlimited | boolean | true when the reward has no stock limit. total_stock is then null and remaining_stock is 0 |
discount_type | string|null | percentage or fixed_amount for a discount, the same values as type_specific_data.discount_type. null for a free_product |
discount_value | integer|null | The size of the discount: a percentage as a whole number (10 is 10%), a fixed amount in euro cents (500 is €5.00). null for a free_product |
max_redemptions_per_customer | integer|null | How many times one customer can redeem this reward. null means no limit. A redemption the shop cancelled does not count |
customer_redemptions | integer|null | How many times the customer named by identifier redeemed this reward, cancelled ones left out. null when you sent no identifier or it matched nobody |
Availability
Rewards can have a limited availability window defined by available_from and available_until. GET /rewards filters on status only: it also lists rewards outside their window and sold-out rewards, and POST /redemptions refuses those with REWARD_UNAVAILABLE or OUT_OF_STOCK. Compare the dates and check remaining_stock and is_unlimited before you offer a reward.
A reward can also cap how often one customer gets it, with max_redemptions_per_customer. The limit counts for all time. A cancelled redemption gives the customer that chance back; a code that ran out unused still counts, because the customer already paid the points for it. Send GET /rewards?identifier=CARD-001 and each reward carries customer_redemptions, how many times that customer has redeemed it. Offer the reward only while that number is below the limit. Once it reaches the limit, POST /redemptions refuses with REDEMPTION_LIMIT_REACHED (422).
Redemption flow
1. Customer selects a reward
The POS system lists available rewards and the customer chooses one. Use the list rewards endpoint to fetch active rewards.
2. Submit a redemption
POST /api/v1/redemptions
{
"identifier": "CARD-001",
"reward_id": 5,
"idempotency_key": "redeem-2024-0042"
}
The API validates:
- The customer has sufficient wallet balance
- The reward is active and available
- The reward has remaining stock
- The reward allows the branch the redemption is recorded at
- The customer has not yet redeemed the reward the most times the shop allows
The idempotency_key makes the call safe to retry: re-sending the same key
returns the original redemption instead of deducting points twice.
Where the redemption happened
branch is your own key for the shop: the external_id you gave it. It follows the same chain a
transaction's branch does: the key in the payload wins, otherwise the API credential's default branch,
otherwise none. A key matching none of your branches is refused with BRANCH_NOT_FOUND (422), so
a typo cannot pass for an unattributed redemption.
A reward that names branches may only be redeemed at one of them. Send no branch, or a branch
outside its set, and the call is refused with BRANCH_REQUIRED (422). One code for both, because
both have the same fix: name a branch the reward allows.
The branch is recorded once. A replay of the same idempotency_key returns the original
redemption with the branch the first call recorded, even when the replay names a different one.
3. Confirmation code issued
On success, the API deducts points from the customer's wallet and returns a redemption with a confirmation code:
{
"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"
}
}
4. Verify the redemption
When the customer presents their confirmation code, verify it:
POST /api/v1/redemptions/{code}/verify
This changes the redemption status from valid to used. A code is verified once: a second verify answers CODE_ALREADY_USED.
Redemption status
| Status | Description |
|---|---|
| valid | Code issued, the customer has not collected the reward yet |
| used | The customer got the reward: the code was verified, or given at the counter |
| expired | The code ran out of time before anyone verified it |
| cancelled | The shop cancelled the redemption: the points and the stock came back |
A reward that the shop gives at the counter in the admin portal is used at once. The customer has it
in their hands, so the code is closed and no till can verify it a second time.
A shop admin can cancel a valid or a used redemption on the customer page. The customer gets back the
points the redemption took, and the reward goes back into stock. A till that verifies a cancelled code gets
CODE_CANCELLED. An expired code cannot be cancelled.
Confirmation code expiration
When the vendor sets in the portal how many hours a reward's confirmation code stays valid, the code expires after that time. Expired codes cannot be verified. The expires_at field in the redemption response indicates when the code will expire.
Looking up a redemption
You can look up a redemption by its confirmation code:
GET /api/v1/redemptions/{code}
This is useful for POS systems that need to display redemption details before verifying.
See Redemption endpoints for the complete API reference.