Core concepts
Customers
A customer is an end user enrolled in a vendor's loyalty program. Customers earn points through transactions and spend them on rewards.
Customer data
| Field | Type | Description |
|---|---|---|
id | integer | Unique customer ID |
first_name | string | Customer's first name |
last_name | string | Customer's last name |
email | string | Email address (unique per vendor) |
phone | string | Phone number |
date_of_birth | date | Date of birth |
customer_since | date | Optional start date for this customer relationship. Left empty, Puntjes counts from the day the record was created. |
locale | string | Preferred language (nl or en, default: nl) |
status | string | Current status: active, deactivated, or anonymized |
Identifiers
Each customer has one or more identifiers: lookup values used to find the customer during transactions at the POS. There are two types:
| Type | Description |
|---|---|
loyalty_card | An 8-character code of uppercase letters and digits (A-Z, 0-9). Puntjes issues one automatically, or the vendor pre-generates a batch and has them printed on physical cards. |
email | The customer's email address. |
Identifier values are unique within a vendor, and lookup is case-insensitive.
Your own IDs
An identifier is how a customer is recognised. Your external_id is separate: it is your own
key for that customer (the record number in your POS or webshop) stored on the customer and
unique within your vendor. Puntjes never invents one, and it is not used for lookup by scanning.
Set it at registration, or, for customers who registered before you integrated, attach it later
with POST /customers/link-external-id. Once linked you can read and
update that customer by your own ID instead of storing ours.
Loyalty cards
Every customer gets a loyalty card. You do not have to invent a card number: if none is
supplied at registration, Puntjes issues one and returns it as loyalty_card_code.
Vendors who want physical cards generate a batch in the admin portal first, export the codes as CSV for a print shop, and hand the printed cards out at the counter. Scanning such a card during registration assigns that specific card to the customer. See Managing customers for the workflow.
A customer can end up holding more than one card (up to five), most often after losing one
and being handed a replacement. Exactly one of them is the card they scan: assigning a card
in the admin portal makes it that card, so in practice it is the most recently assigned one.
It is what loyalty_card_code reports and what their wallet pass shows. Their older cards
stay active and keep working when scanned, so nothing you handed out before stops being
usable.
The email identifier is managed for you
The email identifier is a projection of the customer's profile email address. Puntjes creates it, keeps it in step with every later change to the address, and removes it when the address is cleared. It cannot be edited or deactivated on its own. Change the customer's email instead, and the identifier follows.
Identifier status
Loyalty card identifiers can be independently activated or deactivated. Deactivated identifiers cannot be used for customer lookup or transaction submission.
Older identifier values still work
Puntjes previously accepted several additional identifier types for different scanning technologies. Those types
were consolidated into loyalty_card. Existing values were migrated and still resolve on lookup, so cards already
in customers' wallets keep working, but they can no longer be created, and an API client sending a retired type
receives a validation error. See Error handling.
Customer status lifecycle
Active → Deactivated
Active → Anonymized
| Status | Description |
|---|---|
| Active | Customer can earn and spend points normally |
| Deactivated | Customer is disabled; no new transactions or redemptions |
| Anonymized | Personal data removed for GDPR compliance; excluded from all queries by default |
Anonymization is irreversible
Anonymizing a customer permanently removes their personal data. Anonymized customers are excluded from all API responses and queries. This operation cannot be undone.
Merged customers
A shop can merge two accounts of the same person in the admin portal. One account stays and the other closes. The
closed account answers as a deactivated customer: is_deactivated is true and its balance is 0. Its active
loyalty cards and its purchases now belong to the account that stays, so a lookup or a sale with one of those cards
finds that account. When the account that stays has its own email address, a lookup with the email address of the
closed account answers 404 CUSTOMER_NOT_FOUND. When both accounts had an external_id, the closed account keeps
its own: a lookup by that id returns the closed account with is_deactivated: true and balance 0, and an update by
that id answers 404 EXTERNAL_ID_NOT_FOUND. When you adjust the wallet of a closed account or download its wallet
pass, the answer is 422 CUSTOMER_DEACTIVATED.
Registration
Register a new customer via the API by providing their personal details. No identifier is required. Puntjes issues a loyalty card and returns its code:
POST /api/v1/customers
{
"first_name": "Jan",
"last_name": "De Vries",
"email": "jan@example.com",
"phone": "+31612345678",
"date_of_birth": "1990-05-15",
"locale": "nl"
}
To hand out a pre-printed card instead, scan it and send its code:
{
"first_name": "Jan",
"last_name": "De Vries",
"identifiers": [{ "type": "loyalty_card", "value": "K7M2QX4P" }]
}
A wallet is created for each new customer, and it starts at 0 points. The first purchase earns the first points. A shop on Plus or higher can add a welcome gift for that first purchase with a campaign.
Marketing consent
Campaign email (a birthday message, an anniversary reward) goes only to customers who
explicitly asked for it. One flag decides that, marketing_consent, and it is off for every
customer until somebody sets it to true. Nothing is grandfathered in: customers who existed
before the flag did are unsubscribed, and so is every imported customer, except one the import file
marks as subscribed when whoever ran the import confirmed they can show that record (see
Managing customers).
Puntjes stores the decision as provenance rather than as a bare boolean. An opt-in records the
moment, the surface it came through, who recorded it and the version of the wording the customer
saw (marketingConsentStatementVersion), and a later withdrawal records its own moment and surface
without erasing the first set. A grant from a Piggy import never showed the customer a Puntjes
wording, so that field holds piggy-import-attestation: and the date of the import instead: the
day the shop confirmed Piggy's subscribed mark. An ITReflex import does the same with
itreflex-import-attestation: and the date: the day the shop confirmed the till's mailing list.
Consent you cannot date and attribute is consent you
cannot demonstrate, which is the situation a data-protection request puts you in.
| Source | Meaning |
|---|---|
portal | A vendor user ticked the box in the admin portal |
api | Your integration sent marketing_consent |
email | The customer used the unsubscribe link in an email |
import | An attested import, recorded by the login that ran it |
Three surfaces can change it: your integration, through marketing_consent on
POST /customers or the update endpoint; a vendor user, on
Edit customer in the admin portal; and the customer themselves, through the unsubscribe link
carried by campaign email. That link is signed and never expires, so a year-old message still
works, and following it needs no account and no password.
Withdrawal takes effect immediately and asks for no confirmation. marketingConsent reads
false on the next request, and the grant timestamp stays where it was so you can still see
when the customer had opted in.
Omitting the flag on an update is not the same as sending false
On PUT|PATCH /customers/by-external-id/{external_id}, leaving marketing_consent out of the body leaves the
stored decision untouched. Only an explicit false withdraws it. A nightly POS sync that pushes names and phone
numbers therefore cannot unsubscribe the people it touches. See Customer endpoints for the
full rule, including what an explicit null means.
Lookup
Find a customer by any of their active identifiers:
GET /api/v1/customers/lookup?identifier=K7M2QX4P
The response includes the customer's profile data along with their current wallet balance. This is the primary way POS systems identify customers at checkout.
See Customer endpoints for the complete API reference.