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 will expire in the near future (if expiration is enabled) |
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 | 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 |
Paginate with ?page=. It is read from the query string by the paginator itself, so it
never appears in the parameter table above.
Response
{
"data": {
"data": [
{
"id": 1,
"wallet_id": 1,
"type": "earn",
"amount": 250,
"running_balance": 1500,
"reason": "Points earned from transaction #42",
"causer_type": "user",
"causer_id": "0",
"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 |
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.
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": "Points earned from transaction #42",
"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":"Points earned from transaction #42","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": "earn",
"amount": 250,
"running_balance": 1500,
"reason": "Points earned from transaction #42",
"causer_type": "user",
"causer_id": "0",
"created_at": "2024-03-15T12:30:00Z"
}
}
Idempotency
The idempotency_key must be unique per wallet. If you adjust with an
idempotency_key that already exists for this customer 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.
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_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.
Errors
| Code | Status | Description |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | No customer found with the given ID |
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
}
}
Two 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. 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_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 |
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_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 |