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

ParameterTypeRequiredDescription
identifierstringNoThe identifier value to search for: a loyalty card code or an email address
external_idstringNoYour 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

CodeStatusDescription
CUSTOMER_NOT_FOUND404No 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

FieldTypeRequiredDescription
first_namestringNoCustomer's first name
last_namestringNoCustomer's last name
emailstringNoEmail address (unique per vendor)
phonestringNoPhone number
external_idstringNoYour own identifier for this customer (unique per vendor)
date_of_birthstringNoDate of birth (YYYY-MM-DD)
customer_sincestringNoThe date this person became your customer (YYYY-MM-DD). Omit it and Puntjes counts from the registration date.
localestringNoPreferred language: nl or en (default: nl)
marketing_consentbooleanNoOpt-in for campaign email. Defaults to off.
identifiersarrayNoArray of identifier objects. May be omitted or empty.

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:

FieldTypeRequiredDescription
typestringYesemail or loyalty_card. No other value is accepted
valuestringYesThe 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.

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

CodeStatusDescription
EXTERNAL_ID_DUPLICATE409The external_id is already registered for this vendor
IDENTIFIER_DUPLICATE409The identifier is already registered, including a card that now belongs to another customer
LOYALTY_CARD_NOT_FOUND422The 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

ParameterTypeDescription
externalIdstringThe external_id you assigned this customer in your own system

Every field is optional; send only what changed.

Request body

FieldTypeRequiredDescription
first_namestringNoMax 255 characters
last_namestringNoMax 255 characters
emailstringNoValid email address, max 255 characters
phonestringNoMax 50 characters
date_of_birthstringNoYYYY-MM-DD
customer_sincestringNoYYYY-MM-DD. Send null to clear it and fall back to the registration date.
localestringNonl or en
marketing_consentbooleanNotrue 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 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

CodeStatusDescription
EXTERNAL_ID_NOT_FOUND404No live customer of yours has this 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

FieldTypeRequiredDescription
identifierstringYesA loyalty card code or email address the customer already has
external_idstringYesYour 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

CodeStatusDescription
CUSTOMER_ALREADY_LINKED409The customer is already linked to a different external_id
CUSTOMER_NOT_FOUND404No customer of yours carries that identifier
EXTERNAL_ID_DUPLICATE409Another 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

ParameterTypeDescription
customerstringThe 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

CodeStatusDescription
CUSTOMER_NOT_FOUND404Customer not found or belongs to a different vendor
Previous
Overview