API-referentie
Verzilvering-endpoints
Endpoints om beloningen te verzilveren, 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.) |
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"
},
"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"
}
}
}
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 |
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 | {"discount_value", "discount_type"} bij kortingsbeloningen, {"product_reference"} bij gratis-productbeloningen |
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.
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 |
REWARD_NOT_FOUND | 404 | Geen beloning gevonden met het opgegeven reward_id |
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.
Padparameters
| Parameter | Type | Beschrijving |
|---|---|---|
code | string | De bevestigingscode |
Response
{
"data": {
"redemption_id": 12,
"confirmation_code": "PNTJ-K4MP7QRS",
"status": "valid",
"reward": {
"name": "Gratis koffie",
"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 tot de code geverifieerd is, daarna used. Een code die
voorbij zijn expires_at is, springt bij het opzoeken op expired.
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.
Padparameters
| Parameter | Type | Beschrijving |
|---|---|---|
code | string | De bevestigingscode |
Response
{
"data": {
"redemption_id": 12,
"confirmation_code": "PNTJ-K4MP7QRS",
"status": "valid",
"reward": {
"name": "Gratis koffie",
"type": "free_product"
},
"points_deducted": 500,
"verified_at": null,
"type_specific_data": {
"product_reference": "COFFEE-REG"
}
}
}
Fouten
| Code | Status | Beschrijving |
|---|---|---|
CODE_ALREADY_USED | 422 | Deze verzilvering is al geverifieerd |
CODE_EXPIRED | 422 | De bevestigingscode is verlopen |
REDEMPTION_NOT_FOUND | 404 | Geen verzilvering gevonden met deze code |
VERIFICATION_FAILED | 422 | De verzilvering kon niet geverifieerd worden |
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 exact de vorm
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, en er is geen alleen-lezen
opzoekroute om hem eerst te bekijken. Twee keer aanroepen geeft VOUCHER_ALREADY_USED. Roep hem aan op het moment
dat je het cadeau meegeeft, en lees uit de response waar de klant recht op heeft in plaats van het vooraf op te
halen.
Padparameters
| Parameter | Type | Beschrijving |
|---|---|---|
code | string | De boncode die de klant toont, BON-XXXXXXXX |
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. |
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.
Een bon met een vast bedrag draagt een andere sleutel binnen discount:
{ "kind": "fixed", "amount_cents": 750 }
Een productbon draagt helemaal geen korting. In products staat wat de
kassamedewerker meegeeft, vastgelegd op het moment dat de bon werd uitgegeven:
{
"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 }
]
}
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.kind | string | percentage of fixed | |
discount.percentage | integer | Aanwezig wanneer kind percentage is. Een geheel getal van 1 tot en met 100. | |
discount.amount_cents | integer | Aanwezig wanneer kind fixed is. Hele eurocenten. 750 is € 7,50. | |
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". |
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.
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. |
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.
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 met een BON--code uit een campagnemail binnenkomt volgt een aparte, kortere
weg: roep één keer POST /vouchers/{code}/verify aan, lees kind, en pas de korting toe of
geef de producten mee die de bon noemt.