API-referentie
Verzilvering-endpoints
Endpoints om beloningen te verzilveren, de verzilveringen van een klant op te vragen, de gegevens van een verzilvering op te zoeken, en aan de kassa bevestigingscodes en campagnebonnen te verifiëren.
Een beloning verzilveren
POST /api/v1/redemptions
Schrijf punten af van de wallet van een klant en geef een bevestigingscode uit voor de beloning.
Request body
| Veld | Type | Verplicht | Beschrijving |
|---|---|---|---|
identifier | string | Ja | Klant-identifier (kaartnummer, e-mail, enz.), maximaal 255 tekens |
reward_id | integer | Ja | De beloning die je verzilvert |
idempotency_key | string | Ja | Unieke sleutel die dubbele verzilveringen voorkomt (max. 255 tekens) |
branch | string | Nee | Je eigen sleutel voor de vestiging waar deze beloning is afgegeven: de external_id die je die vestiging gaf, maximaal 64 tekens. Die gaat voor op de standaardvestiging van de API-credential, zodat één gedeelde credential per aanroep een andere winkel kan noemen. Laat het veld weg om de standaardvestiging van de credential te gebruiken, en laat beide weg om de verzilvering zonder vestiging vast te leggen. Een beloning die maar bij bepaalde vestigingen te verzilveren is, moet er een van die vestigingen krijgen. |
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": "Gratis koffie",
"type": "free_product",
"id": 5
},
"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",
"payment_amount": 200
},
"branches": [
{
"external_id": "shop-1",
"name": "Gent",
"type": "physical"
}
]
}
}
Responsevelden
| Veld | Beschrijving |
|---|---|
redemption_id | Het ID van de verzilvering. Gebruik het om die aan je eigen administratie te koppelen |
confirmation_code | Unieke code die de klant aan de kassa toont, in het formaat PNTJ-XXXXXXXX |
reward | De beloning die is ingewisseld: {"name", "type", "id"}. id is de id die GET /rewards voor deze beloning geeft |
points_deducted | Aantal punten dat van de wallet is afgeschreven |
remaining_balance | Het saldo van de wallet na de verzilvering |
redeemed_at | Wanneer de verzilvering is aangemaakt |
expires_at | Wanneer de bevestigingscode verloopt (als de beloning een code_valid_for_hours heeft), anders null |
type_specific_data | {"type", "discount_value", "discount_type", "product_reference"} bij kortingsbeloningen, {"type", "product_reference", "payment_amount"} bij gratis-productbeloningen. type is discount of free_product: lees het om de vorm te kiezen |
branches | De vestigingen waar de beloning ingewisseld mag worden, elk {"external_id", "name", "type"}. null betekent elke vestiging |
Bij een korting is product_reference het artikelnummer van het ene product waarop de korting
geldt: de external_id van dat product in je catalogus. Geldt de korting op de hele aankoop, dan
is het null, net als bij elke korting die is ingewisseld voordat dit veld bestond. Een vast
bedrag kan hoger uitvallen dan de prijs van dat product. Hoe je dat begrenst, bepaal je in je kassa.
In het antwoord, zonder de andere velden:
{
"data": {
"type_specific_data": {
"type": "discount",
"discount_value": 20,
"discount_type": "percentage",
"product_reference": "THERMO-01"
}
}
}
payment_amount is wat je kassa bovenop de punten aanrekent, in eurocenten (0 = alleen punten).
Die betaling loopt niet via Puntjes. Puntjes kopieert type_specific_data op het moment van de
verzilvering. Pas je de beloning daarna aan, dan geven het opzoeken, de lijst van de klant en de
verificatie nog altijd de oude waarden terug.
De vestigingsregel van de beloning geldt wanneer de beloning wordt ingewisseld, met branch op POST /redemptions.
Ze geldt opnieuw wanneer de code wordt geverifieerd: zie POST /redemptions/{code}/verify.
branches toont de vestigingen van de beloning zoals ze nu zijn, niet zoals ze bij de inwisseling waren.
null betekent elke vestiging.
GET /redemptions/{code}, de lijst met inwisselingen van een klant en het antwoord van de verificatie hebben ook reward.id en branches.
Idempotentie
De idempotency_key moet uniek zijn per handelaar. Verzilver je met een
idempotency_key die al bestaat, dan:
- krijg je de oorspronkelijke verzilvering terug, met dezelfde
confirmation_code - gaan er geen punten een tweede keer af
- komt er geen nieuwe grootboekregel bij en gaat de voorraad niet opnieuw omlaag
Zo kun je de aanroep na een netwerkfout veilig opnieuw versturen. Je krijgt ook
antwoord wanneer de wallet de beloning intussen niet meer kan betalen: de
oorspronkelijke verzilvering heeft er al voor betaald, dus een retry faalt nooit
met INSUFFICIENT_BALANCE. remaining_balance toont het saldo van nu, niet dat
op het moment van de oorspronkelijke verzilvering. Je krijgt de oorspronkelijke
verzilvering ook terug als ze later geannuleerd is. Zoek de code op met
GET /redemptions/{code} om de huidige status te zien.
Kies je idempotency keys zorgvuldig
Gebruik een waarde die de verzilvering uniek identificeert in jouw systeem, zoals een order-ID of een UUID die je
één keer per afrekening genereert. Vermijd willekeurige waarden die bij een retry veranderen. Dat maakt idempotentie
zinloos. Hergebruik je dezelfde sleutel voor een andere klant of een andere beloning, dan krijg je
IDEMPOTENCY_KEY_CONFLICT terug.
Fouten
| Code | Status | Beschrijving |
|---|---|---|
BRANCH_NOT_FOUND | 422 | Geen enkele vestiging van jou heeft deze sleutel. Inactieve vestigingen tellen mee als gevonden: bij het verzilveren telt alleen welke winkel het was, niet of ze nog open is. |
BRANCH_REQUIRED | 422 | De beloning is maar bij een deel van je vestigingen te verzilveren, en deze aanroep noemt er geen: er staat helemaal geen branch in, of een die de beloning niet toelaat. Stuur een vestiging die de beloning wel toelaat. |
CUSTOMER_DEACTIVATED | 422 | Het klantaccount is gedeactiveerd |
CUSTOMER_NOT_FOUND | 404 | Geen klant gevonden met de opgegeven identifier |
IDEMPOTENCY_KEY_CONFLICT | 422 | Deze idempotency_key is al gebruikt voor een andere klant of beloning |
INSUFFICIENT_BALANCE | 422 | De klant heeft niet genoeg punten |
NO_WALLET | 422 | De klant heeft geen wallet |
OUT_OF_STOCK | 422 | De beloning is niet meer op voorraad |
REDEMPTION_LIMIT_REACHED | 422 | De klant wisselde deze beloning al zo vaak in als de winkel toestaat |
REWARD_NOT_FOUND | 404 | Geen beloning gevonden met het opgegeven reward_id, ook een die de handelaar heeft ingetrokken |
REWARD_UNAVAILABLE | 422 | De beloning is inactief of buiten de beschikbaarheidsperiode |
Een verzilvering opzoeken
GET /api/v1/redemptions/{code}
Zoek een verzilvering op met de bevestigingscode. Zo toon je de gegevens van de verzilvering voordat je ze verifieert.
Path parameters
| Parameter | Type | Beschrijving |
|---|---|---|
code | string | De bevestigingscode. Hoofdletters of kleine letters werken allebei, en spaties ervoor of erna tellen niet mee |
Response
{
"data": {
"redemption_id": 12,
"confirmation_code": "PNTJ-K4MP7QRS",
"status": "valid",
"reward": {
"name": "Gratis koffie",
"type": "free_product",
"id": 5
},
"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",
"payment_amount": 200
},
"branches": [
{
"external_id": "shop-1",
"name": "Gent",
"type": "physical"
}
]
}
}
status is valid tot de code geverifieerd is, daarna used. Een code die
voorbij zijn expires_at is, springt bij het opzoeken op expired. Een verzilvering
die de zaak in het beheerportaal annuleerde, is cancelled en blijft dat ook na haar
expires_at. Een beloning die de zaak in het beheerportaal meteen aan de klant gaf, is
van bij het begin used.
De verzilveringen van een klant opvragen
GET /api/v1/customers/{customer}/redemptions
Alle beloningen die één klant bij je heeft verzilverd, de nieuwste eerst, 15 per pagina. Gebruik
dit als de klant aan de kassa staat zonder zijn PNTJ--code: zoek de klant op met zijn
klantenkaart via GET /customers/lookup, vraag deze lijst op met status=valid, en verifieer de
beloning die de klant kiest met POST /redemptions/{code}/verify.
Path parameters
| Parameter | Type | Beschrijving |
|---|---|---|
customer | string | Het klant-ID, zoals GET /customers/lookup het teruggeeft |
Query parameters
| Parameter | Type | Verplicht | Standaard | Beschrijving |
|---|---|---|---|---|
status | string | Nee | — | Eén van valid, used, expired of cancelled. Alleen een valid code voorbij zijn expires_at telt als expired; een used of cancelled code houdt haar status |
page | integer | Nee | 1 | Welke pagina je terugkrijgt. Een waarde onder 1, of een waarde die geen geheel getal is, geeft pagina 1 |
Response
{
"data": {
"data": [
{
"redemption_id": 12,
"confirmation_code": "PNTJ-K4MP7QRS",
"status": "valid",
"reward": {
"name": "Gratis koffie",
"type": "free_product",
"id": 5
},
"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",
"payment_amount": 200
},
"branches": [
{
"external_id": "shop-1",
"name": "Gent",
"type": "physical"
}
]
}
],
"links": {
"first": "string",
"last": "string",
"prev": "string",
"next": "string"
},
"meta": {
"current_page": 1,
"last_page": 2,
"per_page": 15,
"total": 20
}
}
}
Elk item heeft dezelfde velden als GET /redemptions/{code}, zodat één parser ze allebei leest.
status is de status op het moment dat je de lijst opvraagt. Een code die voorbij zijn
expires_at is, staat op expired en valt buiten status=valid, ook als nog niemand hem heeft
opgezocht. Een beloning die de zaak in het beheerportaal meteen gaf, is used, en een
geannuleerde verzilvering is cancelled. Geen van beide staat dus in status=valid.
Fouten
| Code | Status | Beschrijving |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | Je hebt geen klant met dit ID |
Een verzilvering verifiëren
POST /api/v1/redemptions/{code}/verify
Markeer een verzilvering als geverifieerd. Daarmee bevestig je dat de klant de beloning gekregen heeft.
Een code verifieer je één keer. Een beloning die de zaak in het beheerportaal meteen gaf, is al
used, dus die code antwoordt CODE_ALREADY_USED. Een geannuleerde verzilvering antwoordt
CODE_CANCELLED: de klant heeft zijn punten terug, dus geef de beloning niet mee.
Path parameters
| Parameter | Type | Beschrijving |
|---|---|---|
code | string | De bevestigingscode. Hoofdletters of kleine letters werken allebei, en spaties ervoor of erna tellen niet mee |
Request body
| Veld | Type | Verplicht | Beschrijving |
|---|---|---|---|
branch | string | Nee | Je eigen sleutel voor de vestiging waar de beloning wordt meegegeven: de external_id die je die vestiging gaf, maximaal 64 tekens. Die gaat voor op de standaardvestiging van je API-credential. Een beloning met branches moet een van die vestigingen krijgen, hoe ze ook binnenkomt. |
idempotency_key | string | Nee | Je eigen sleutel voor deze verificatie, maximaal 255 tekens. Stuur dezelfde sleutel opnieuw als een antwoord zoekraakt: de herhaling geeft het eerste geslaagde antwoord. Een sleutel verifieert één code: bij een andere code geeft hij IDEMPOTENCY_KEY_CONFLICT |
De body is optioneel. Een beloning zonder vestigingen verifieer je zonder body.
Een body die niet te lezen is, geeft 400 INVALID_JSON, en een verkeerde waarde geeft 422 VALIDATION_ERROR.
Idempotentie
Stuur bij elke verificatie een idempotency_key mee.
Raakt het antwoord zoek, stuur dan dezelfde aanroep opnieuw met dezelfde sleutel.
De herhaling geeft het eerste geslaagde antwoord, met dezelfde verified_at.
Zonder sleutel geeft een tweede aanroep CODE_ALREADY_USED.
Een sleutel verifieert één code.
Stuur je hem mee met een andere code, dan geeft dat IDEMPOTENCY_KEY_CONFLICT, en die code blijft valid.
De sleutel van een verificatie staat los van de idempotency_key van de inwisseling, dus je mag één waarde voor beide gebruiken.
Vestiging
Een beloning met branches verifieer je alleen bij een van die vestigingen.
De vestiging wordt bepaald zoals bij POST /redemptions: de branch die je meestuurt, anders de standaardvestiging van je API-credential, anders geen.
Is er geen vestiging, of een die de beloning niet toelaat, dan geeft de aanroep BRANCH_REQUIRED, en de code blijft valid.
Een beloning waarvan branches null is, verifieer je bij elke vestiging.
Een verificatie van een beperkte beloning zonder bekende vestiging slaagde vóór deze regel. Ze geeft nu BRANCH_REQUIRED.
Nog drie aanroepen die eerder slaagden, geven nu 422.
Een branch die geen vestiging noemt, bij een beloning zonder branches, geeft BRANCH_NOT_FOUND.
Een idempotency_key van meer dan 255 tekens geeft VALIDATION_ERROR.
Eén idempotency_key die je met twee codes meestuurt, geeft bij de tweede code IDEMPOTENCY_KEY_CONFLICT.
branches is de lijst die de beloning nu heeft.
Een herhaling met de sleutel die de code verifieerde, wordt beantwoord voordat de vestigingsregel wordt toegepast, ook als de vestigingen van de beloning intussen zijn veranderd.
Een branch-sleutel die geen vestiging van jou noemt, geeft nog steeds BRANCH_NOT_FOUND, ook bij een herhaling.
Example request
{
"branch": "shop-1",
"idempotency_key": "verify-2024-0042"
}
curl -X POST https://puntjes.app/api/v1/redemptions/1/verify \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"branch":"shop-1","idempotency_key":"verify-2024-0042"}'
Response
{
"data": {
"redemption_id": 12,
"confirmation_code": "PNTJ-K4MP7QRS",
"status": "used",
"reward": {
"name": "Gratis koffie",
"type": "free_product",
"id": 5
},
"points_deducted": 500,
"verified_at": "2024-03-15T12:45:00+00:00",
"type_specific_data": {
"product_reference": "COFFEE-REG",
"payment_amount": 200
},
"branches": [
{
"external_id": "shop-1",
"name": "Gent",
"type": "physical"
}
]
}
}
Fouten
| Code | Status | Beschrijving |
|---|---|---|
BRANCH_NOT_FOUND | 422 | Geen enkele vestiging van jou heeft deze sleutel. Inactieve vestigingen tellen mee als gevonden: bij het verifiëren telt alleen welke winkel het was, niet of ze nog open is. |
BRANCH_REQUIRED | 422 | De beloning mag maar bij een deel van je vestigingen worden geverifieerd, en deze aanroep noemt er geen: er staat helemaal geen branch in, of een die de beloning niet toelaat. De code blijft valid; stuur een vestiging die de beloning wel toelaat en roep opnieuw aan. |
CODE_ALREADY_USED | 422 | Deze verzilvering is al geverifieerd |
CODE_CANCELLED | 422 | De zaak heeft deze verzilvering in het beheerportaal geannuleerd en de punten teruggegeven, dus de code werkt niet meer |
CODE_EXPIRED | 422 | De bevestigingscode is verlopen |
IDEMPOTENCY_KEY_CONFLICT | 422 | Deze idempotency_key heeft al een andere code geverifieerd. De code in deze aanroep is niet geverifieerd; stuur een nieuwe sleutel |
REDEMPTION_NOT_FOUND | 404 | Geen verzilvering gevonden met deze code |
VERIFICATION_FAILED | 422 | De verzilvering kon niet geverifieerd worden |
Een bon opzoeken
GET /api/v1/vouchers/{code}
Lees wat een campagnebon geeft zonder hem te verbruiken. Een kassa die een BON--code scant,
kan de kassier de korting of de producten tonen, nakijken of ze op de open verkoop staan, en pas
daarna POST /vouchers/{code}/verify aanroepen. Deze aanroep verandert niets: de bon blijft
zoals hij is.
Het antwoord heeft dezelfde velden als het antwoord van verify, plus status. Een bon die al
verbruikt is en intussen verlopen is, leest used: hetzelfde feit dat verify meldt.
Path parameters
| Parameter | Type | Beschrijving |
|---|---|---|
code | string | De boncode die de klant toont, BON-XXXXXXXX. Hoofdletters of kleine letters werken allebei, en spaties ervoor of erna tellen niet mee |
Response
{
"data": {
"voucher_code": "BON-7K2MQX4P",
"discount": null,
"valid_until": "2026-08-31",
"consumed_at": "2026-08-12T10:14:02+00:00",
"campaign_id": 7,
"kind": "free_product",
"products": [
{
"id": 41,
"name": "Croissant",
"quantity": 2,
"product_reference": "CROISSANT-01"
}
],
"status": "valid"
}
}
Fouten
| Code | Status | Beschrijving |
|---|---|---|
VOUCHER_NOT_FOUND | 404 | Geen enkele bon van jou draagt deze code |
Een code van een andere handelaar geeft dezelfde 404 als een code die nooit is uitgegeven, byte voor byte, om de reden die het stuk over verify hieronder geeft.
Een bon verifiëren
POST /api/v1/vouchers/{code}/verify
Bonnen
Een bon is wat een campagnegeschenk aan één klant geeft: de verjaardagsmail die met 15% korting binnenkomt, of die met twee croissants en een koffie. Het is geen beloning en kost geen punten. De klant heeft hem niet gekozen en niet betaald, dus er valt niets te verzilveren en er is geen wallet om van af te schrijven. Wat er wél is, is een code om in te leveren.
kind zegt welke van de twee je in handen hebt. Een discount-bon draagt een
discount-object dat je kassa toepast; een free_product-bon draagt products, de artikelen
die je meegeeft. Eén code dekt het hele geschenk, hoeveel producten het ook noemt, dus een
cadeau van twee artikelen is één scan en één aanroep.
Boncodes bestaan uit BON- gevolgd door acht tekens, zodat ze aan de kassa en in een
supportgesprek niet te verwarren zijn met de PNTJ--bevestigingscodes hierboven.
Splits op `kind`, niet op welk veld gevuld is
Elke bon die is uitgegeven voordat productbonnen bestonden is een discount-bon, en discount houdt elke sleutel
die het altijd had. Een client die discount leest zonder kind te controleren krijgt null zodra een handelaar
voor het eerst een product weggeeft. Lees eerst kind en behandel alles wat je niet kent als een korting.
Deze aanroep verbruikt de bon
Verifiëren is niet vooraf bekijken. Een geslaagde aanroep markeert de bon als gebruikt. Wil je eerst zien wat een
bon geeft, roep dan GET /vouchers/{code} aan. Stuur bij elke verify een idempotency_key mee: raakt het antwoord
zoek, dan geeft dezelfde aanroep met dezelfde sleutel opnieuw het eerste geslaagde antwoord. Zonder die sleutel
geeft een tweede aanroep VOUCHER_ALREADY_USED.
Path parameters
| Parameter | Type | Beschrijving |
|---|---|---|
code | string | De boncode die de klant toont, BON-XXXXXXXX. Hoofdletters of kleine letters werken allebei, en spaties ervoor of erna tellen niet mee |
Request body
| Veld | Type | Verplicht | Beschrijving |
|---|---|---|---|
branch | string | Nee | Je eigen sleutel voor de vestiging waar de bon wordt verzilverd: de external_id die je die vestiging gaf, maximaal 64 tekens. Die gaat voor op de standaardvestiging van je API-credential. Laat beide weg voor een bon die geen vestigingen noemt; een bon die er wel noemt moet er één krijgen, langs welke weg dan ook. |
idempotency_key | string | Nee | Je eigen sleutel voor deze verzilvering, maximaal 255 tekens. Stuur dezelfde sleutel opnieuw als een antwoord zoekraakt: de herhaling geeft het eerste geslaagde antwoord. Een sleutel dient maar één keer: bij een andere bon geeft hij IDEMPOTENCY_KEY_CONFLICT |
De body is optioneel. Een bon van een campagne die overal loopt verzilver je zonder body:
curl -X POST https://puntjes.app/api/v1/vouchers/BON-7K2MQX4P/verify \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Bij een bon van een campagne die maar bij een deel van je vestigingen loopt, moet je
er één noemen. De vestiging wordt op dezelfde manier bepaald als bij
POST /transactions en POST /redemptions: eerst de branch die je meestuurt, dan
de standaardvestiging van je API-credential, dan niets. Een kassa waarvan de
credential haar eigen winkel al noemt, heeft hier dus ook geen body nodig.
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"}'
Welke vestigingen een bon toelaat ligt vast op het moment dat de campagne hem
uitgeeft; de kassa leest dat niet opnieuw uit de campagne. De campagne daarna
aanpassen verandert wat de volgende bon toelaat en nooit wat een openstaande bon
toelaat. Dat is dezelfde regel die products en valid_until volgen, en daarom is
de vestiging in de mail van de klant ook de vestiging die de kassa aanvaardt.
Response
{
"data": {
"voucher_code": "BON-7K2MQX4P",
"discount": null,
"valid_until": "2026-08-31",
"consumed_at": "2026-08-12T10:14:02+00:00",
"campaign_id": 7,
"kind": "free_product",
"products": [
{
"id": 41,
"name": "Croissant",
"quantity": 2,
"product_reference": "CROISSANT-01"
}
]
}
}
Dat is een productbon. Een kortingsbon is de andere vorm: discount is ingevuld en
products is null.
{
"data": {
"voucher_code": "BON-7K2MQX4P",
"discount": { "type": "percentage", "kind": "percentage", "percentage": 15, "product_reference": null },
"valid_until": "2026-08-31",
"consumed_at": "2026-08-12T10:14:02+00:00",
"campaign_id": 7,
"kind": "discount",
"products": null
}
}
Een bon met een vast bedrag draagt een andere sleutel binnen data.discount. Deze geldt op één product:
{ "type": "fixed", "kind": "fixed", "amount_cents": 750, "product_reference": "THERMO-01" }
Responsevelden
| Veld | Type | Beschrijving |
|---|---|---|
voucher_code | string | De code die je zonet verbruikt hebt |
discount | object|null | Aanwezig wanneer kind discount is, null bij een productbon |
discount.type | string | percentage of fixed |
discount.kind | string | Verouderd: dezelfde waarde als discount.type. Lees type. |
discount.percentage | integer | Aanwezig wanneer discount.type percentage is. Een geheel getal van 1 tot en met 100. |
discount.amount_cents | integer | Aanwezig wanneer discount.type fixed is. Hele eurocenten. 750 is € 7,50. |
discount.product_reference | string|null | Het artikelnummer van het ene product waarop de korting geldt; null als ze op de hele aankoop geldt |
valid_until | string|null | De laatste dag waarop de bon geldig is, of null als hij nooit vervalt |
consumed_at | string | ISO 8601-tijdstip van deze aanroep |
campaign_id | integer | De campagne met het geschenk dat deze bon uitgaf |
kind | string | Altijd aanwezig: discount of free_product |
products | array|null | Aanwezig wanneer kind free_product is, anders null |
products[].id | integer|null | Het product in je catalogus, of null bij een regel die er nooit een had |
products[].name | string | De naam van het product zoals het geschenk die beloofde |
products[].quantity | integer | Hoeveel stuks je ervan meegeeft. Weergeven doe je zoals je wil: het portaal toont "2× Croissant". |
products[].product_reference | string|null | Het artikelnummer van dat product, zodat je kassa het op de verkoop kan zetten; null voor een regel zonder catalogusproduct |
Het bedrag staat onder een sleutel die naar zijn soort genoemd is en niet onder één gedeelde
value. Met één gedeelde sleutel kan een client een fixed-bon als percentage lezen, en dan
wordt € 7,50 korting plots 750% korting. Met twee sleutels krijg je in plaats daarvan een fout
over een ontbrekende sleutel in je eigen code.
discount.product_reference is de external_id van dat product in je catalogus, vastgelegd
op het moment dat de bon werd uitgegeven. Een vast bedrag kan hoger uitvallen dan de prijs van het
product. Hoe je dat begrenst, bepaal je in je kassa. Een bon die is uitgegeven voordat dit
veld bestond, geeft null.
products[].product_reference is de external_id van elk product, vastgelegd toen de bon
werd uitgegeven, net als discount.product_reference. Elke bon van voor dit veld bestond,
geeft null.
products is een kopie van het moment waarop de bon werd uitgegeven, geen live uitlezing van
de campagne. Het geschenk daarna aanpassen verandert wat de volgende bon belooft en nooit wat
deze bon oplevert. Dat is dezelfde regel die valid_until volgt. Productbonnen dragen vandaag
valid_until: null: het geldigheidsvenster is een instelling van de kortingsbon, en een
geschenk zonder venster vervalt nooit.
Fouten
| Code | Status | Beschrijving |
|---|---|---|
BRANCH_NOT_FOUND | 422 | Geen enkele vestiging van jou heeft deze sleutel. Inactieve vestigingen tellen mee als gevonden: bij het verzilveren van een bon telt alleen welke winkel het was, niet of ze nog open is. |
BRANCH_REQUIRED | 422 | De bon is maar bij een deel van je vestigingen te verzilveren, en deze aanroep noemt er geen: er staat helemaal geen branch in, of een die de bon niet toelaat. De bon is niet verbruikt; stuur een vestiging die hij wel toelaat en roep opnieuw aan. |
IDEMPOTENCY_KEY_CONFLICT | 422 | Deze idempotency_key heeft al een andere bon verbruikt. De bon in deze aanroep is niet verbruikt; stuur een nieuwe sleutel |
VOUCHER_ALREADY_USED | 422 | De bon is al verbruikt |
VOUCHER_EXPIRED | 422 | De bon is niet meer geldig |
VOUCHER_NOT_FOUND | 404 | Geen enkele bon van jou draagt deze code |
Een code van een andere handelaar geeft dezelfde 404 als een code die nooit is uitgegeven: dezelfde status en dezelfde body, byte voor byte. Zou er ook maar iets verschillen, dan kon een client de hele coderuimte aflopen en ontdekken welke codes echt bestaan, zonder er ooit een te kunnen verzilveren.
Een bon die vorige maand is verbruikt en sindsdien is verlopen meldt VOUCHER_ALREADY_USED en
niet VOUCHER_EXPIRED. Dat is wat de kassamedewerker moet weten: "verlopen" leest als nooit
ingeleverd, en nodigt uit tot een tweede korting bovenop de korting die de klant al kreeg.
Geldigheid loopt tot en met de genoemde dag
valid_until is een datum en geen tijdstip, en de bon is op die dag nog geldig. De mail
aan de klant belooft "geldig tot 30 september", en op de 30e langskomen is dus zijn goed recht.
De datum ligt vast zodra de bon wordt uitgegeven, dus het geldigheidsvenster van de campagne
later inkorten verkort geen bon die al in iemands inbox ligt. Een geschenk zonder venster geeft
een bon met valid_until: null, die nooit vervalt.
Puntjes verplaatst het geld niet
Verifiëren schrijft één tijdstip weg en verder niets: geen transactie, geen grootboekregel, geen punten in welke richting dan ook. De korting toepassen is het werk van je kassa, en dit endpoint legt vast wat er beloofd is en of het is ingeleverd.
De open bonnen van een klant opvragen
GET /api/v1/customers/{customer}/vouchers
Alle campagnebonnen die één klant nog bij je kan verzilveren, in één lijst zonder pagina's.
Gebruik dit als de klant aan de kassa staat zonder zijn BON--code: zoek de klant op met zijn
klantenkaart via GET /customers/lookup, vraag deze lijst op, en verzilver de bon die de klant
kiest met POST /vouchers/{code}/verify.
Path parameters
| Parameter | Type | Beschrijving |
|---|---|---|
customer | string | Het klant-ID, zoals GET /customers/lookup het teruggeeft |
Response
{
"data": [
{
"voucher_code": "BON-7K2MQX4P",
"campaign_id": 7,
"kind": "free_product",
"discount": null,
"products": [
{
"id": 41,
"name": "Croissant",
"quantity": 2,
"product_reference": "CROISSANT-01"
}
],
"valid_until": "2026-08-31",
"branches": [
{
"external_id": "shop-1",
"name": "Gent",
"type": "physical"
}
]
}
]
}
De bon die het eerst afloopt, staat bovenaan. Een bon zonder laatste dag staat onderaan. Een bon
die al verbruikt is, of waarvan valid_until voorbij is, staat niet in de lijst. Op zijn
laatste dag blijft een bon de hele dag (Belgische tijd) in de lijst staan, en zolang aanvaardt
verify hem ook.
kind, discount en products zien er hetzelfde uit als in GET /vouchers/{code}, zodat één
parser ze allebei leest. branches noemt de vestigingen die de bon aanvaarden, en is null
voor een bon die elke vestiging aanvaardt. Een kassa in een andere vestiging kan de bon toch
tonen en de klant zeggen waar hij hem kan gebruiken, want verify geeft daar BRANCH_REQUIRED.
Een gedeactiveerde klant krijgt zijn lijst nog, want zijn bonnen zijn nog te verzilveren.
Fouten
| Code | Status | Beschrijving |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | Je hebt geen klant met dit ID |
Typische kassaflow
- De klant toont zijn klantenkaart bij het afrekenen
- De kassa roept
GET /customers/lookupaan om het saldo te tonen - De klant kiest een beloning uit
GET /rewards - De kassa roept
POST /redemptionsaan om de beloning te verzilveren - De klant krijgt een bevestigingscode (op het kasticket, via sms of op het scherm)
- Later, wanneer de klant de code toont, roept de kassa
POST /redemptions/{code}/verifyaan
Een klant die zonder code terugkomt, kan zijn beloning toch ophalen: na stap 2 roept de kassa
GET /customers/{customer}/redemptions?status=valid aan, de kassier kiest de beloning uit die lijst,
en de kassa verifieert de confirmation_code ervan.
Een klant die met een BON--code uit een campagnemail binnenkomt volgt een aparte, kortere
weg: roep GET /vouchers/{code} aan om te zien wat de bon geeft, lees kind, kijk de korting of
de producten na tegen de open verkoop, roep dan POST /vouchers/{code}/verify aan met een
idempotency_key, en pas de korting toe of geef de producten mee.
Een klant met een campagnecadeau maar zonder de BON--code volgt dezelfde weg, met één aanroep
meer: na stap 2 roept de kassa GET /customers/{customer}/vouchers aan en de kassier kiest de bon
uit die lijst.