API reference
Reward endpoints
Endpoints for listing available rewards and active campaigns.
List rewards
GET /api/v1/rewards
Retrieve every reward whose status is active, cheapest first. The list also holds rewards whose
dates have passed, rewards that have not started yet and rewards that are sold out. Before you
offer one, compare today with available_from and available_until, and check that
remaining_stock is above 0 unless is_unlimited is true. This list is not paginated.
data is the bare array, unlike every other index endpoint on this API.
A reward can also cap how often one customer gets it. Before you offer a reward whose
max_redemptions_per_customer is set, send the customer's identifier and check that
customer_redemptions is below that number. POST /redemptions refuses the next one with
REDEMPTION_LIMIT_REACHED.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
affordable | boolean | No | false | Return only rewards the customer can pay for. Needs identifier; if that resolves to no customer or no wallet, the full catalogue comes back unfiltered |
identifier | string | No | — | A loyalty card code or other identifier value. It names the customer whose redemptions customer_redemptions counts, and with affordable also the balance that filter reads. Works without affordable |
Response
{
"data": [
{
"id": 1,
"name": "10% Discount",
"description": "Get 10% off your next purchase",
"type": "discount",
"point_cost": 500,
"payment_amount": 0,
"image_url": null,
"remaining_stock": 50,
"total_stock": 100,
"available_from": "2026-03-01",
"available_until": "2026-12-31",
"branches": [
{
"external_id": "shop-1",
"name": "Ghent",
"type": "physical"
}
],
"product_reference": null,
"is_unlimited": false,
"discount_type": "percentage",
"discount_value": 10,
"max_redemptions_per_customer": 2,
"customer_redemptions": null
}
]
}
Response fields
| Field | Description |
|---|---|
payment_amount | What the till collects on top of the points, in cents. 0 means points only, and a discount is always 0. |
remaining_stock | Redemptions still available. 0 for a reward with no limit: read is_unlimited first. |
total_stock | Redemptions the reward started with. null means unlimited. |
available_from | YYYY-MM-DD, not a timestamp. These two dates are the exception on this API |
available_until | YYYY-MM-DD. null means the reward does not expire. |
branches | The branches the reward may be redeemed at, each { "external_id", "name", "type" }, in the order you keep them. null means every branch. |
product_reference | 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 | true when the reward has no stock limit. total_stock is then null and remaining_stock is 0. |
discount_type | percentage or fixed_amount for a discount, the same values as type_specific_data.discount_type of a redemption. null for a free_product. |
discount_value | The size of the discount, in the unit of type_specific_data.discount_value: a percentage as a whole number (10 is 10%), a fixed amount in cents (500 is €5.00). null for a free_product. |
max_redemptions_per_customer | How many times one customer can redeem this reward. null means no limit. A redemption the shop cancelled does not count. |
customer_redemptions | 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. |
Use product_reference, discount_type and discount_value to check before you redeem: for a
discount on one product, is that product on the open sale? Does this sale allow a percentage, or
only a fixed amount? Settle the reward with type_specific_data from the redemption, not with
this list: the redemption keeps the values it had at that moment, and the shop may change the
reward later; see Redemption endpoints.
Filtered on status only
An inactive reward is left out. A reward outside its dates or sold out stays in the list, and POST /redemptions
refuses it with REWARD_UNAVAILABLE or OUT_OF_STOCK.
Reward dates are UTC days, so compare them with today's date in UTC: a reward whose
available_until is today can be redeemed until midnight UTC, which is 01:00 or 02:00 in Brussels. Campaign dates are Brussels days. Each field
says which calendar it reads.
List campaigns
GET /api/v1/campaigns
Retrieve a paginated list of campaigns whose status is active: running, upcoming and ended ones.
A campaign whose end date has passed keeps the status active and stays in this list. Before
you show one as running, compare today's date in Brussels with starts_at and ends_at: a
campaign applies from its starts_at day through its ends_at day, on the Brussels calendar
(Europe/Brussels), not on UTC days.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
branch | string | No | — | Your own key for one branch: the external_id you gave it, at most 64 characters. Answers with the campaigns that run there: the ones scoped to that branch, plus every campaign that names no branch at all. Pass none for the campaigns that apply to a purchase recorded without a branch. A key matching none of your branches answers 422 BRANCH_NOT_FOUND rather than the unfiltered list, so a typo cannot pass for a report. An empty or blank branch answers the same; leave the parameter out for every branch |
page | integer | No | 1 | Which page to return. A value below 1, or one that is not a whole number, returns page 1 |
Page size is fixed at 15
This endpoint does not honour per_page. Of the list endpoints only GET /products accepts it.
Breaking type change: `multiplier` is now nullable
multiplier was always an integer; it is now null for customer-moment and fixed-points campaigns.
recurrence_type and recurrence_config became nullable in the same change. A strict client that types all three
as always-present will fail to parse the two campaign families introduced alongside them. Widen the types before
consuming this endpoint.
The list is double-wrapped: the outer data is the standard response envelope, and the
paginator sits inside it, so campaigns live at data.data.
Response
{
"data": {
"data": [
{
"id": 1,
"name": "10% Discount",
"family": "transaction",
"moment": null,
"config": {
"stackable": false,
"reward_type": "multiplier",
"scope": "whole"
},
"version": 1,
"multiplier": 2,
"recurrence_type": "days_of_week",
"recurrence_config": {
"days": "saturday"
},
"schedule_summary": "Every Saturday and Sunday",
"starts_at": "2024-03-01",
"ends_at": "2024-06-30",
"status": {
"value": "active",
"label": "Active"
},
"min_transaction_amount": 25,
"branches": [
{
"external_id": "shop-1",
"name": "Ghent",
"type": "physical"
}
],
"created_at": "2024-02-20T09:15:00+00:00",
"updated_at": "2024-02-20T09:15:00+00:00"
}
],
"links": {
"first": "https://puntjes.app/api/v1/campaigns?page=1",
"last": "https://puntjes.app/api/v1/campaigns?page=1",
"prev": null,
"next": null
},
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 15,
"total": 2
}
}
}
Response fields
| Field | Type | Description |
|---|---|---|
family | string | transaction (triggered by a purchase) or customer_moment (triggered by the customer) |
moment | string|null | For customer_moment: birthday, anniversary, first_purchase, nth_purchase or win_back |
config | object|null | Family-specific settings. Its type names the shape: transaction, moment, anniversary, nth_purchase or win_back. See below |
version | integer | Bumped on every material edit. Stamped onto the points a purchase earns. |
multiplier | integer|null | Whole multiple, minimum 2: 2 = 2x, 3 = 3x. null unless this campaign uses a multiplier. |
recurrence_type | string|null | date_range, days_of_week or specific_dates. null when the campaign has no schedule. |
recurrence_config | object|null | Configuration for the recurrence type. Its type repeats recurrence_type. null when there is no recurrence. |
schedule_summary | string | Human-readable schedule. "Always active" when there is no recurrence. Not translated server-side. |
starts_at | string | Calendar date, YYYY-MM-DD, on the Brussels calendar. The first day the campaign applies |
ends_at | string|null | Calendar date, YYYY-MM-DD, on the Brussels calendar. The last day the campaign applies; from the next day on, it no longer applies to a purchase while its status stays active. null means open-ended. |
status | object | { "value": "active", "label": "Active" }. label is English on every request. See below |
min_transaction_amount | number|null | Minimum purchase for the campaign to apply, in euros. See below |
branches | array|null | The branches this campaign runs at, each { "external_id", "name", "type" }. null means all of them. See below |
created_at | string|null | ISO 8601 timestamp |
updated_at | string|null | ISO 8601 timestamp |
status.label is always English
There is no request locale on this API and no server-side translation, so label carries the English word whichever
language your integration speaks. Map value yourself. On this endpoint it is always active: the list holds
active campaigns only, and an ended campaign keeps that status. Compare the dates instead.
min_transaction_amount is in euros, not cents
This is the one field on the API that is not expressed in cents. 25.0 means EUR 25.00, not EUR 0.25. Every other
amount (total_amount, unit_price, price_cents and the statistics figures) remains an integer number of cents.
Read the scope from `branches`, not from `config.branch_ids`
branches carries the external_id of every branch the campaign runs at, which is the key your own systems already
use. config.branch_ids holds the same scope as Puntjes primary keys: config is passed through as stored, and
those ids are meaningless outside the portal. branches is null when the campaign names no branch, which means it
runs at every one of them, including purchases recorded without a branch. An empty array means the opposite: the
campaign is scoped, and every branch it named has since been deleted, so it now matches nothing.
The config object
For family: "transaction":
| Key | Description |
|---|---|
scope | whole, whole_purchase or products. whole and whole_purchase mean the same: the whole purchase. A campaign from an older release can carry whole_purchase |
reward_type | multiplier (uses the multiplier field) or fixed_points (uses points) |
points | Points per matching unit, for fixed_points |
max_points_per_purchase | Ceiling on what one purchase may earn from this campaign. Absent or null means no cap. |
products | For products scope: a fixed snapshot of { "sku", "name" } pairs |
stackable | true when this campaign may stack with others; otherwise only the strongest pays |
For family: "customer_moment": the moment's own settings (for example every_n and
min_amount on nth_purchase, years on anniversary, months on win_back) plus a gift
object describing what the moment hands out. min_amount is in cents, unlike
min_transaction_amount beside it.
config, recurrence_config and each gift carry a type that names their shape: read it to
choose how to parse the object. A gift's type is discount, free_product or points.
type is present when the shape is known. A config has none for a moment campaign with no moment.
A gift.discount has none when its kind is not percentage or fixed.
A discount gift holds its amount in gift.discount: type (percentage or fixed), then
percentage or amount_cents. kind holds the same value but is deprecated: read type. When the discount is for one product, gift.discount.product_id
is that product's id as GET /products returns it. Absent or null means the whole purchase.
The till does not need to read it here: verifying the bon returns the product's external_id as
discount.product_reference.
Campaigns written before this contract
A campaign created by an earlier release may have a null or partial config. Such a row behaves as a
whole-purchase, non-combinable campaign with a multiplier. Read the defaults; do not assume the keys are present.
Errors
| Code | Status | Description |
|---|---|---|
BRANCH_NOT_FOUND | 422 | branch is empty or matches none of your branches, deactivated ones included. The filter is refused rather than ignored, so a misspelled key cannot pass for a report covering every branch |