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

VeldTypeVerplichtBeschrijving
identifierstringJaKlant-identifier (kaartnummer, e-mail, enz.)
reward_idintegerJaDe beloning die je verzilvert
idempotency_keystringJaUnieke sleutel die dubbele verzilveringen voorkomt (max. 255 tekens)
branchstringNeeJe 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

VeldBeschrijving
redemption_idHet ID van de verzilvering. Gebruik het om die aan je eigen administratie te koppelen
confirmation_codeUnieke code die de klant aan de kassa toont, in het formaat PNTJ-XXXXXXXX
points_deductedAantal punten dat van de wallet is afgeschreven
remaining_balanceHet saldo van de wallet na de verzilvering
redeemed_atWanneer de verzilvering is aangemaakt
expires_atWanneer 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

CodeStatusBeschrijving
BRANCH_NOT_FOUND422Geen 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_REQUIRED422De 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_DEACTIVATED422Het klantaccount is gedeactiveerd
CUSTOMER_NOT_FOUND404Geen klant gevonden met de opgegeven identifier
IDEMPOTENCY_KEY_CONFLICT422Deze idempotency_key is al gebruikt voor een andere klant of beloning
INSUFFICIENT_BALANCE422De klant heeft niet genoeg punten
NO_WALLET422De klant heeft geen wallet
OUT_OF_STOCK422De beloning is niet meer op voorraad
REWARD_NOT_FOUND404Geen beloning gevonden met het opgegeven reward_id
REWARD_UNAVAILABLE422De 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

ParameterTypeBeschrijving
codestringDe 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

ParameterTypeBeschrijving
codestringDe 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

CodeStatusBeschrijving
CODE_ALREADY_USED422Deze verzilvering is al geverifieerd
CODE_EXPIRED422De bevestigingscode is verlopen
REDEMPTION_NOT_FOUND404Geen verzilvering gevonden met deze code
VERIFICATION_FAILED422De 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

ParameterTypeBeschrijving
codestringDe boncode die de klant toont, BON-XXXXXXXX

Request body

VeldTypeVerplichtBeschrijving
branchstringNeeJe 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

VeldTypeBeschrijving
voucher_codestringDe code die je zonet verbruikt hebt
discountobject\nullAanwezig wanneer kind discount is, null bij een productbon
discount.kindstringpercentage of fixed
discount.percentageintegerAanwezig wanneer kind percentage is. Een geheel getal van 1 tot en met 100.
discount.amount_centsintegerAanwezig wanneer kind fixed is. Hele eurocenten. 750 is € 7,50.
valid_untilstring\nullDe laatste dag waarop de bon geldig is, of null als hij nooit vervalt
consumed_atstringISO 8601-tijdstip van deze aanroep
campaign_idintegerDe campagne met het geschenk dat deze bon uitgaf
kindstringAltijd aanwezig: discount of free_product
productsarray\nullAanwezig wanneer kind free_product is, anders null
products[].idinteger\nullHet product in je catalogus, of null bij een regel die er nooit een had
products[].namestringDe naam van het product zoals het geschenk die beloofde
products[].quantityintegerHoeveel 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

CodeStatusBeschrijving
BRANCH_NOT_FOUND422Geen 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_REQUIRED422De 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_USED422De bon is al verbruikt
VOUCHER_EXPIRED422De bon is niet meer geldig
VOUCHER_NOT_FOUND404Geen 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

  1. De klant toont zijn klantenkaart bij het afrekenen
  2. De kassa roept GET /customers/lookup aan om het saldo te tonen
  3. De klant kiest een beloning uit GET /rewards
  4. De kassa roept POST /redemptions aan om de beloning te verzilveren
  5. De klant krijgt een bevestigingscode (op het kasticket, via sms of op het scherm)
  6. Later, wanneer de klant de code toont, roept de kassa POST /redemptions/{code}/verify aan

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.