API reference
Customer endpoints
Endpoints for looking up, registering, updating, linking, and viewing customers.
status.label is always English
A customer's status arrives as { "value": "active", "label": "Active" }. 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. It is one of active, deactivated or
anonymized, and those three are what the Puntjes portal translates.
Look up a customer
GET /api/v1/customers/lookup
Find a customer by any of their active identifiers (their loyalty card code or their email
address), or by the external_id you assigned them in your own system.
Matching is case-insensitive: a scanner with Caps Lock on resolves the same customer as one without. It is also deliberately type-agnostic. The value is compared against every active identifier of your vendor regardless of its type, so identifier values issued before Puntjes narrowed the accepted types still resolve unchanged. Nothing you ever handed a customer stops working.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier | string | No | The identifier value to search for: a loyalty card code or an email address |
external_id | string | No | Your own identifier for this customer, as supplied when registering them |
Provide exactly one of the two. Omitting both is a VALIDATION_ERROR; external_id
takes precedence if you send both.
curl https://puntjes.app/api/v1/customers/lookup?identifier=K7M2QX4P \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Response
{
"data": {
"id": 1,
"first_name": "Jan",
"last_name": "De Vries",
"email": "jan@example.com",
"phone": "+31612345678",
"external_id": "POS-4471",
"date_of_birth": "1990-05-15",
"customer_since": "2019-04-02",
"locale": "nl",
"status": {
"value": "string",
"label": "string"
},
"identifiers": [
{
"id": 1,
"type": "loyalty_card",
"value": "K7M2QX4P",
"is_active": true,
"is_primary": true,
"created_at": "2024-03-15T12:30:00Z"
}
],
"marketingConsent": false,
"marketingConsentGrantedAt": "string",
"marketingConsentGrantedSource": "string",
"marketingConsentWithdrawnAt": "string",
"marketingConsentWithdrawnSource": "string",
"loyalty_card_code": "K7M2QX4P",
"deactivated_at": null,
"anonymized_at": null,
"created_at": "2024-03-15T12:30:00Z",
"updated_at": "string"
}
}
Errors
| Code | Status | Description |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | No customer found with the given identifier |
Register a customer
POST /api/v1/customers
Create a new customer. A wallet and a loyalty card are created automatically.
Every customer ends registration holding exactly one loyalty card. You either scan a
pre-printed card and Puntjes assigns it, or you send no card at all and Puntjes issues one.
Either way the resulting code comes back as loyalty_card_code in the response.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
first_name | string | No | Customer's first name |
last_name | string | No | Customer's last name |
email | string | No | Email address (unique per vendor) |
phone | string | No | Phone number |
external_id | string | No | Your own identifier for this customer (unique per vendor) |
date_of_birth | string | No | Date of birth (YYYY-MM-DD) |
customer_since | string | No | The date this person became your customer (YYYY-MM-DD). Omit it and Puntjes counts from the registration date. |
locale | string | No | Preferred language: nl or en (default: nl) |
marketing_consent | boolean | No | Opt-in for campaign email. Defaults to off. |
identifiers | array | No | Array of identifier objects. May be omitted or empty. |
Marketing consent at registration
Send marketing_consent: true only when the person in front of you actually said yes.
Puntjes stamps the moment and records api as the surface it came through, and that pair is
the evidence behind every campaign email you later send them.
Omitting the field and sending false do the same thing here: a customer who does not exist
yet has no consent to withdraw. So an integration that has never heard of this field registers
everyone unsubscribed, which is the intended default. Anything other than a boolean is
rejected with VALIDATION_ERROR.
The identifiers array
Each entry is an object, not a bare string:
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | email or loyalty_card. No other value is accepted |
value | string | Yes | The identifier value (max 255 characters) |
At most one loyalty_card entry may be sent per registration; a customer carries one
physical card. A loyalty_card value must match a card your vendor has already generated
and that is not yet assigned to anyone. See Register with a scanned card.
You do not need to send an email entry to make the address searchable. When you supply
email on the customer itself, Puntjes maintains the matching identifier for you and keeps
it in step with every later change to the address.
Register without a card (recommended)
Omit identifiers entirely and let Puntjes issue the card. This is the simplest integration:
there is no code to invent and no stock to keep.
{
"first_name": "Jan",
"last_name": "De Vries",
"email": "jan@example.com",
"phone": "+31612345678",
"date_of_birth": "1990-05-15",
"locale": "nl"
}
curl -X POST https://puntjes.app/api/v1/customers \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"first_name":"Jan","last_name":"De Vries","email":"jan@example.com"}'
Register with a scanned card
Send the code printed on the physical card the customer was handed. The card must already exist as unassigned stock for your vendor. Cards are generated in the admin portal under
Loyalty cards and sent to a print shop from there.
{
"first_name": "Jan",
"last_name": "De Vries",
"email": "jan@example.com",
"identifiers": [{ "type": "loyalty_card", "value": "K7M2QX4P" }]
}
curl -X POST https://puntjes.app/api/v1/customers \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"first_name":"Jan","last_name":"De Vries","identifiers":[{"type":"loyalty_card","value":"K7M2QX4P"}]}'
Codes are matched case-insensitively, so a scanner that inverts case still finds the right card. The stored identifier always carries the casing that is printed on the card, not the casing that was scanned.
Response (201 Created)
{
"data": {
"id": 1,
"first_name": "Jan",
"last_name": "De Vries",
"email": "jan@example.com",
"phone": "+31612345678",
"external_id": "POS-4471",
"date_of_birth": "1990-05-15",
"customer_since": "2019-04-02",
"locale": "nl",
"status": {
"value": "string",
"label": "string"
},
"identifiers": [
{
"id": 1,
"type": "loyalty_card",
"value": "K7M2QX4P",
"is_active": true,
"is_primary": true,
"created_at": "2024-03-15T12:30:00Z"
}
],
"marketingConsent": false,
"marketingConsentGrantedAt": "string",
"marketingConsentGrantedSource": "string",
"marketingConsentWithdrawnAt": "string",
"marketingConsentWithdrawnSource": "string",
"loyalty_card_code": "K7M2QX4P",
"deactivated_at": null,
"anonymized_at": null,
"created_at": "2024-03-15T12:30:00Z",
"updated_at": "string"
}
}
loyalty_card_code is the customer's card code hoisted to the top level so you do not have
to filter the identifiers array to find it. Persist it as the customer's scannable value
the moment registration returns. It is what a later lookup will match on.
It is a nullable string. In practice it is only null for customers created before loyalty
cards existed and for customers whose identifiers have been erased by anonymization; a
successful registration always returns a code.
A customer can hold several cards (up to five), for example after losing one and being
handed a replacement at the counter. loyalty_card_code then reports the one card that is
marked as theirs to scan, which is also what their wallet pass shows. Assigning a card in
the admin portal makes that card the one to scan, so in practice it is the most recently
assigned one; a customer migrated from another loyalty system instead keeps whichever card
that system marked as their default.
Every other card stays active and keeps resolving at GET /customers/lookup, so a code you
persisted earlier never stops working. Re-read loyalty_card_code when you want to display
the card the customer is currently carrying.
Errors
| Code | Status | Description |
|---|---|---|
EXTERNAL_ID_DUPLICATE | 409 | The external_id is already registered for this vendor |
IDENTIFIER_DUPLICATE | 409 | The identifier is already registered, including a card that now belongs to another customer |
LOYALTY_CARD_NOT_FOUND | 422 | The submitted loyalty_card value is not an unassigned card of this vendor |
Handling LOYALTY_CARD_NOT_FOUND at the counter
A submitted loyalty_card value must match a card your vendor has already generated and
that nobody holds yet. A code that was never generated, one that belongs to a different
vendor, and a mistyped code all return the same 422. The API deliberately does not reveal
which.
The remedy is to rescan, or to retry without the identifiers array and let Puntjes issue a
card. No customer, wallet or card is created when this error is returned, so the retry is
clean.
Uniqueness
Email addresses and external_id values must be unique within a vendor, as must every identifier value. Registering
a duplicate returns a 409 rather than a validation error, so your client can tell "this customer already exists"
apart from "this request is malformed".
Update a customer
PUT /api/v1/customers/by-external-id/{externalId}
Update an existing customer, resolved by the external_id you assigned them. This is the
write side of the POS sync: a change made at the register (a corrected email, a new phone
number) reaches Puntjes without you having to store our customer ID.
Both PUT and PATCH behave identically, and both are partial updates: only the fields
present in the body are changed. Omitting a field leaves it untouched. Sending null
explicitly clears it.
The external_id is the immutable key and lives in the URL, so it cannot be changed here.
Loyalty cards are not touched by this endpoint either. Cards are managed on their own
routes. The customer must be live: soft-deleted, deactivated and anonymized customers are
not found, and yield EXTERNAL_ID_NOT_FOUND rather than being silently recreated.
Path parameters
| Parameter | Type | Description |
|---|---|---|
externalId | string | The external_id you assigned this customer in your own system |
Every field is optional; send only what changed.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
first_name | string | No | Max 255 characters |
last_name | string | No | Max 255 characters |
email | string | No | Valid email address, max 255 characters |
phone | string | No | Max 50 characters |
date_of_birth | string | No | YYYY-MM-DD |
customer_since | string | No | YYYY-MM-DD. Send null to clear it and fall back to the registration date. |
locale | string | No | nl or en |
marketing_consent | boolean | No | true grants campaign-email consent, false withdraws it. Omit to leave the stored decision alone. |
Changing email also updates the customer's email identifier, which Puntjes manages for
you. You never add or remove that one by hand.
Omitting marketing_consent is not the same as sending false
Omitting the field leaves the stored decision exactly as it is. Sending false withdraws it.
That difference is the entire reason the field behaves the way it does: a POS that pushes a
corrected phone number every evening must not unsubscribe every customer it touches, and
neither you nor the customer would have any way to notice that it had.
{
"email": "jan.devries@example.com",
"phone": "+31698765432"
}
curl -X PATCH https://puntjes.app/api/v1/customers/by-external-id/POS-4471 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"jan.devries@example.com","phone":"+31698765432"}'
Returns the full updated customer, in the same shape as GET /customers/{customer}.
Response
{
"data": {
"id": 1,
"first_name": "Jan",
"last_name": "De Vries",
"email": "jan@example.com",
"phone": "+31612345678",
"external_id": "POS-4471",
"date_of_birth": "1990-05-15",
"customer_since": "2019-04-02",
"locale": "nl",
"status": {
"value": "string",
"label": "string"
},
"identifiers": [
{
"id": 1,
"type": "loyalty_card",
"value": "K7M2QX4P",
"is_active": true,
"is_primary": true,
"created_at": "2024-03-15T12:30:00Z"
}
],
"marketingConsent": false,
"marketingConsentGrantedAt": "string",
"marketingConsentGrantedSource": "string",
"marketingConsentWithdrawnAt": "string",
"marketingConsentWithdrawnSource": "string",
"loyalty_card_code": "K7M2QX4P",
"deactivated_at": null,
"anonymized_at": null,
"created_at": "2024-03-15T12:30:00Z",
"updated_at": "string"
}
}
Errors
| Code | Status | Description |
|---|---|---|
EXTERNAL_ID_NOT_FOUND | 404 | No live customer of yours has this external_id |
Link an external ID
POST /api/v1/customers/link-external-id
Attach your own external_id to a customer who does not have one yet, found by an identifier
they already carry: their loyalty card code or their email address.
This is the backfill route. Customers who registered before you integrated your POS have no
external_id, so PUT|PATCH /customers/by-external-id/{external_id} cannot address them and
re-registering them would collide on their identifiers. Link them once, and every later update
can be keyed on your own ID.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
identifier | string | Yes | A loyalty card code or email address the customer already has |
external_id | string | Yes | Your own key for this customer, unique per vendor |
curl -X POST https://puntjes.app/api/v1/customers/link-external-id \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"identifier":"K7M2QX4P","external_id":"POS-4471"}'
Linking is one-way
Sending the same external_id again succeeds, so retries and repeated backfill runs are safe. Sending a
different one to a customer who is already linked returns CUSTOMER_ALREADY_LINKED rather than overwriting.
Silently re-keying would break whatever system owns the existing ID, whose updates would start failing with no
signal at the point of change. To correct a wrong link, change it in the admin portal.
Returns the full customer, in the same shape as GET /customers/{customer}.
Response
{
"data": {
"id": 1,
"first_name": "Jan",
"last_name": "De Vries",
"email": "jan@example.com",
"phone": "+31612345678",
"external_id": "POS-4471",
"date_of_birth": "1990-05-15",
"customer_since": "2019-04-02",
"locale": "nl",
"status": {
"value": "string",
"label": "string"
},
"identifiers": [
{
"id": 1,
"type": "loyalty_card",
"value": "K7M2QX4P",
"is_active": true,
"is_primary": true,
"created_at": "2024-03-15T12:30:00Z"
}
],
"marketingConsent": false,
"marketingConsentGrantedAt": "string",
"marketingConsentGrantedSource": "string",
"marketingConsentWithdrawnAt": "string",
"marketingConsentWithdrawnSource": "string",
"loyalty_card_code": "K7M2QX4P",
"deactivated_at": null,
"anonymized_at": null,
"created_at": "2024-03-15T12:30:00Z",
"updated_at": "string"
}
}
Errors
| Code | Status | Description |
|---|---|---|
CUSTOMER_ALREADY_LINKED | 409 | The customer is already linked to a different external_id |
CUSTOMER_NOT_FOUND | 404 | No customer of yours carries that identifier |
EXTERNAL_ID_DUPLICATE | 409 | Another of your customers already uses this external_id |
Get customer details
GET /api/v1/customers/{customer}
Retrieve a specific customer by their numeric ID.
Path parameters
| Parameter | Type | Description |
|---|---|---|
customer | string | The customer ID |
Response
{
"data": {
"id": 1,
"first_name": "Jan",
"last_name": "De Vries",
"email": "jan@example.com",
"phone": "+31612345678",
"external_id": "POS-4471",
"date_of_birth": "1990-05-15",
"customer_since": "2019-04-02",
"locale": "nl",
"status": {
"value": "string",
"label": "string"
},
"identifiers": [
{
"id": 1,
"type": "loyalty_card",
"value": "K7M2QX4P",
"is_active": true,
"is_primary": true,
"created_at": "2024-03-15T12:30:00Z"
}
],
"marketingConsent": false,
"marketingConsentGrantedAt": "string",
"marketingConsentGrantedSource": "string",
"marketingConsentWithdrawnAt": "string",
"marketingConsentWithdrawnSource": "string",
"loyalty_card_code": "K7M2QX4P",
"deactivated_at": null,
"anonymized_at": null,
"created_at": "2024-03-15T12:30:00Z",
"updated_at": "string"
}
}
Errors
| Code | Status | Description |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | Customer not found or belongs to a different vendor |