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

ParameterTypeRequiredDefaultDescription
affordablebooleanNofalseReturn only rewards the customer can pay for. Needs identifier; if that resolves to no customer or no wallet, the full catalogue comes back unfiltered
identifierstringNo—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

FieldDescription
payment_amountWhat the till collects on top of the points, in cents. 0 means points only, and a discount is always 0.
remaining_stockRedemptions still available. 0 for a reward with no limit: read is_unlimited first.
total_stockRedemptions the reward started with. null means unlimited.
available_fromYYYY-MM-DD, not a timestamp. These two dates are the exception on this API
available_untilYYYY-MM-DD. null means the reward does not expire.
branchesThe branches the reward may be redeemed at, each { "external_id", "name", "type" }, in the order you keep them. null means every branch.
product_referenceThe 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_unlimitedtrue when the reward has no stock limit. total_stock is then null and remaining_stock is 0.
discount_typepercentage or fixed_amount for a discount, the same values as type_specific_data.discount_type of a redemption. null for a free_product.
discount_valueThe 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_customerHow many times one customer can redeem this reward. null means no limit. A redemption the shop cancelled does not count.
customer_redemptionsHow 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

ParameterTypeRequiredDefaultDescription
branchstringNo—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
pageintegerNo1Which 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

FieldTypeDescription
familystringtransaction (triggered by a purchase) or customer_moment (triggered by the customer)
momentstring|nullFor customer_moment: birthday, anniversary, first_purchase, nth_purchase or win_back
configobject|nullFamily-specific settings. Its type names the shape: transaction, moment, anniversary, nth_purchase or win_back. See below
versionintegerBumped on every material edit. Stamped onto the points a purchase earns.
multiplierinteger|nullWhole multiple, minimum 2: 2 = 2x, 3 = 3x. null unless this campaign uses a multiplier.
recurrence_typestring|nulldate_range, days_of_week or specific_dates. null when the campaign has no schedule.
recurrence_configobject|nullConfiguration for the recurrence type. Its type repeats recurrence_type. null when there is no recurrence.
schedule_summarystringHuman-readable schedule. "Always active" when there is no recurrence. Not translated server-side.
starts_atstringCalendar date, YYYY-MM-DD, on the Brussels calendar. The first day the campaign applies
ends_atstring|nullCalendar 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.
statusobject{ "value": "active", "label": "Active" }. label is English on every request. See below
min_transaction_amountnumber|nullMinimum purchase for the campaign to apply, in euros. See below
branchesarray|nullThe branches this campaign runs at, each { "external_id", "name", "type" }. null means all of them. See below
created_atstring|nullISO 8601 timestamp
updated_atstring|nullISO 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":

KeyDescription
scopewhole, 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_typemultiplier (uses the multiplier field) or fixed_points (uses points)
pointsPoints per matching unit, for fixed_points
max_points_per_purchaseCeiling on what one purchase may earn from this campaign. Absent or null means no cap.
productsFor products scope: a fixed snapshot of { "sku", "name" } pairs
stackabletrue 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

CodeStatusDescription
BRANCH_NOT_FOUND422branch 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