API reference
Wallet endpoints
Endpoints for viewing wallet balances, browsing the point ledger, and making manual adjustments.
Get wallet balance
GET /api/v1/customers/{customer}/wallet
Retrieve the current wallet balance for a customer, including information about points expiring soon.
Path parameters
| Parameter | Type | Description |
|---|---|---|
customer | string | The customer ID |
Response
{
"data": {
"id": 1,
"customer_id": 1,
"balance": 1500,
"expiring_soon": 200,
"created_at": "2024-03-15T12:30:00Z",
"updated_at": "string"
}
}
| Field | Description |
|---|---|
balance | Current total point balance |
expiring_soon | Points that expire in the next 30 days. Every point that already has an end date counts, also after the shop switched expiry off or moved to a plan without it |
Errors
| Code | Status | Description |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | No customer found with the given ID |
WALLET_NOT_FOUND | 404 | The customer has no wallet |
List ledger entries
GET /api/v1/customers/{customer}/ledger
Retrieve a paginated list of all point movements for a customer's wallet. Entries are returned in reverse chronological order (newest first).
Path parameters
| Parameter | Type | Description |
|---|---|---|
customer | string | The customer ID |
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
date_from | string | No | — | Only entries created at or after this point. Compared against created_at, so a bare date starts at midnight UTC |
date_to | string | No | — | Only entries created at or before this point. A bare date therefore stops at midnight, excluding that day |
type | string | No | — | One of earn, adjust, redeem or expire |
page | integer | No | 1 | Which page to return. A value below 1, or one that is not a whole number, returns page 1 |
Response
{
"data": {
"data": [
{
"id": 1,
"wallet_id": 1,
"type": "adjust",
"amount": 250,
"running_balance": 1500,
"reason": "Goodwill after a late delivery",
"causer_type": "api_client",
"causer_id": "1",
"created_at": "2024-03-15T12:30:00Z"
}
],
"links": {
"first": "string",
"last": "string",
"prev": "string",
"next": "string"
},
"meta": {
"current_page": 1,
"last_page": 2,
"per_page": 15,
"total": 20
}
}
}
Ledger entry types
| Type | Amount sign | Description |
|---|---|---|
earn | Positive | Points earned from a transaction |
adjust | Positive or negative | Manual balance adjustment |
redeem | Negative | Points spent on a reward |
expire | Negative | Points expired |
Errors
| Code | Status | Description |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | No customer found with the given ID |
WALLET_NOT_FOUND | 404 | The customer has no wallet |
Adjust wallet balance
POST /api/v1/customers/{customer}/wallet/adjust
Manually add or deduct points from a customer's wallet. Creates a new ledger entry of type adjust.
Added points expire like earned points, and deducted points come out of the points that expire first.
Path parameters
| Parameter | Type | Description |
|---|---|---|
customer | string | The customer ID |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | Yes | Points to add (positive) or deduct (negative). A whole number, not 0, that fits a signed 32-bit integer. |
reason | string | Yes | Explanation for the adjustment (max 500 characters) |
idempotency_key | string | Yes | Unique key to prevent duplicate adjustments (max 255 characters) |
Example request
{
"amount": 250,
"reason": "Goodwill after a late delivery",
"idempotency_key": "goodwill-2024-0042"
}
curl -X POST https://puntjes.app/api/v1/customers/1/wallet/adjust \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"amount":250,"reason":"Goodwill after a late delivery","idempotency_key":"goodwill-2024-0042"}'
The created ledger entry, in the same shape as the entries returned by
GET /customers/{customer}/ledger. running_balance is the wallet balance
after the adjustment.
Response
{
"data": {
"id": 1,
"wallet_id": 1,
"type": "adjust",
"amount": 250,
"running_balance": 1500,
"reason": "Goodwill after a late delivery",
"causer_type": "api_client",
"causer_id": "1",
"created_at": "2024-03-15T12:30:00Z"
}
}
Idempotency
An idempotency_key is unique per customer, and the adjustments of every
account the shop merged into that customer count too. If you adjust with a key
that already exists for this customer, or for an account merged into it, and
the amount matches:
- The original ledger entry is returned
- The balance is not moved a second time
- No new ledger entry is created
This makes the call safe to retry after a network failure. A replay is answered
even when the balance can no longer absorb the adjustment. The original
adjustment already applied it, so a retry never fails with
INSUFFICIENT_BALANCE.
A key that comes back with a different amount is a different adjustment,
not a retry, so it is refused with IDEMPOTENCY_KEY_CONFLICT and nothing is
written. Give each adjustment its own key. reason is not compared: a till may
reword it between retries without meaning anything different by it.
A retry also works across a merge. Sent to the kept customer, a key finds the
adjustments made on the accounts merged into it. Sent to the closed account, a
key used before the merge returns that adjustment, and a new key is refused with
CUSTOMER_DEACTIVATED.
A replay sent to the kept customer can return an adjustment made on an account
merged into it. That entry keeps the closed account's wallet_id, and its
running_balance is that account's balance right after the adjustment, not the
kept customer's balance.
Negative adjustments
When deducting points, ensure the customer has sufficient balance. A negative adjustment that would bring the balance below zero will be rejected.
Errors
| Code | Status | Description |
|---|---|---|
CUSTOMER_DEACTIVATED | 422 | The shop merged this customer into another account, so this wallet is closed. A deactivated customer the shop did not merge still takes adjustments |
CUSTOMER_NOT_FOUND | 404 | No customer found with the given ID |
IDEMPOTENCY_KEY_CONFLICT | 422 | This idempotency_key was already used for a different amount |
INSUFFICIENT_BALANCE | 422 | The adjustment would bring the balance below zero |
WALLET_NOT_FOUND | 404 | The customer has no wallet |
Download a wallet pass
GET /api/v1/customers/{customer}/wallet-pass
Build the customer's loyalty pass for Apple Wallet or Google Wallet. The balance is read at the moment you call this, so a pass is never served with a stale figure.
The two platforms answer differently. apple returns the signed .pkpass binary itself, as
an attachment. Hand the bytes straight to the customer's browser or app. google returns
JSON containing a save link, deliberately not a redirect, because the caller is usually a POS
or webshop backend that needs the URL to render a button.
Path parameters
| Parameter | Type | Description |
|---|---|---|
customer | string | The customer ID |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
platform | string | Yes | Which wallet to build for: apple or google |
Example request
curl https://puntjes.app/api/v1/customers/1/wallet-pass?platform=google \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Response
{
"data": {
"save_url": "https://pay.google.com/gp/v/save/eyJhbGciOiJSUzI1NiJ9"
}
}
The example above is the google branch. With platform=apple the response is
application/vnd.apple.pkpass with a Content-Disposition: attachment header, not JSON.
When building the pass fails on our side, for example at Apple's signing step or at Google's
service, the answer is 500 INTERNAL_ERROR. Try again later, and quote the request_id when it
keeps failing.
Each shop signs its Apple passes with its own Apple identity, which Puntjes asks Apple for when
the shop is created. Until Apple has made it, platform=apple answers 503
APPLE_WALLET_UNAVAILABLE with a Retry-After header in seconds. While Apple is busy or does
not answer, Puntjes asks Apple again at most once a minute per shop, so a retry before that time
gets the same answer at once. When Apple refused the identity, Puntjes asks again only after our
team has fixed the cause, and the answer stays 503 until then. platform=google is not affected.
Errors
| Code | Status | Description |
|---|---|---|
APPLE_WALLET_UNAVAILABLE | 503 | Apple has not finished the shop's Apple identity yet, so no Apple pass can be signed. Retry after the seconds in Retry-After |
CUSTOMER_DEACTIVATED | 422 | The shop merged this customer into another account, so no new pass is built. A deactivated customer the shop did not merge still gets one |
CUSTOMER_NOT_FOUND | 404 | No customer of yours has this ID, or the customer was anonymized and can no longer get a pass |
Send the customer their loyalty card
POST /api/v1/customers/{customer}/send-card
Email the customer their loyalty card while they are still at the counter. It is the
same message the admin portal's send button produces: the signed .pkpass attached,
an "Add to Google Wallet" button, and the card's QR code for scanning in store.
Delivery is queued, so 202 means accepted for sending. Nothing in the response
says the mail arrived.
Path parameters
| Parameter | Type | Description |
|---|---|---|
customer | string | The customer ID |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | No | Where to send the card. email is the only channel Puntjes can deliver on today, and omitting the field means email. |
Example request
{
"channel": "email"
}
curl -X POST https://puntjes.app/api/v1/customers/1/send-card \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel":"email"}'
Response (202 Accepted)
{
"data": {
"customer_id": 1,
"channel": "email",
"queued": true
}
}
Three refusals are worth handling apart from the rest. A customer with no address on
file gets CUSTOMER_HAS_NO_EMAIL, not a generic validation error, so the cashier can
be told to ask for one. An address we stopped mailing gets
CUSTOMER_EMAIL_SUPPRESSED: earlier mail to it bounced, or your mail to it was marked
as spam. A bounce counts for every shop on Puntjes, so an address that bounced at
another shop is refused at yours too. Ask the customer for an address that works, or
switch sending back on from their page in the admin portal. A second send for the same
customer inside the cooldown gets CARD_SEND_THROTTLED with details.retry_after in seconds. That is a cashier
pressing the button twice rather than your integration outrunning its plan, and it
clears on its own after 60 seconds. Sending any channel other than email is a
VALIDATION_ERROR, and a deactivated customer is CUSTOMER_DEACTIVATED.
There is no idempotency_key here, unlike the endpoints that move points. The
cooldown is what makes a retry safe: a repeat inside the window is refused rather
than sent, and a key would not have helped anyway, since ten presses mint ten keys.
Marketing consent is not consulted. The customer asked for their own card at the till, so an opt-out of campaign email does not block it.
Errors
| Code | Status | Description |
|---|---|---|
CARD_SEND_THROTTLED | 429 | This customer was already sent their card moments ago; wait details.retry_after seconds |
CUSTOMER_DEACTIVATED | 422 | The customer left the programme, so no card is sent |
CUSTOMER_EMAIL_SUPPRESSED | 422 | Mail to this address bounced, at your shop or at another one, or your mail to it was marked as spam, so we stopped mailing it. Correct the address, or switch sending back on in the admin portal |
CUSTOMER_HAS_NO_EMAIL | 422 | The customer has no email address on file, so there is nowhere to send the card |
CUSTOMER_NOT_FOUND | 404 | No customer of yours has this ID. A deactivated one is found and refused with CUSTOMER_DEACTIVATED instead |
Send the loyalty card, keyed on your own id
POST /api/v1/customers/by-external-id/{externalId}/send-card
The same send as above, reached by the external_id you gave the customer, so a POS
never has to hold on to our numeric id just to press this button. If you have both,
prefer the id form. It is one index lookup fewer.
The cooldown is keyed on the customer rather than on the route, so alternating between the two forms cannot double the mails.
Path parameters
| Parameter | Type | Description |
|---|---|---|
externalId | string | The external_id you assigned this customer in your own system. One that holds / cannot be used here |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | No | Where to send the card. email is the only channel Puntjes can deliver on today, and omitting the field means email. |
Example request
{
"channel": "email"
}
curl -X POST https://puntjes.app/api/v1/customers/by-external-id/POS-4471/send-card \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel":"email"}'
Response (202 Accepted)
{
"data": {
"customer_id": 1,
"channel": "email",
"queued": true
}
}
Errors
| Code | Status | Description |
|---|---|---|
CARD_SEND_THROTTLED | 429 | This customer was already sent their card moments ago; wait details.retry_after seconds |
CUSTOMER_DEACTIVATED | 422 | The customer left the programme, so no card is sent |
CUSTOMER_EMAIL_SUPPRESSED | 422 | Mail to this address bounced, at your shop or at another one, or your mail to it was marked as spam, so we stopped mailing it. Correct the address, or switch sending back on in the admin portal |
CUSTOMER_HAS_NO_EMAIL | 422 | The customer has no email address on file, so there is nowhere to send the card |
EXTERNAL_ID_NOT_FOUND | 404 | No customer of yours has this external_id. A deactivated one is found and refused with CUSTOMER_DEACTIVATED instead |