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

ParameterTypeDescription
customerstringThe customer ID

Response

{
    "data": {
        "id": 1,
        "customer_id": 1,
        "balance": 1500,
        "expiring_soon": 200,
        "created_at": "2024-03-15T12:30:00Z",
        "updated_at": "string"
    }
}
FieldDescription
balanceCurrent total point balance
expiring_soonPoints 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

ParameterTypeDescription
customerstringThe customer ID

Query parameters

ParameterTypeRequiredDescription
date_fromstringNoOnly entries created at or after this point. Compared against created_at, so a bare date starts at midnight UTC
date_tostringNoOnly entries created at or before this point. A bare date therefore stops at midnight, excluding that day
typestringNoOne 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

TypeAmount signDescription
earnPositivePoints earned from a transaction
adjustPositive or negativeManual balance adjustment
redeemNegativePoints spent on a reward
expireNegativePoints 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

ParameterTypeDescription
customerstringThe customer ID

Request body

FieldTypeRequiredDescription
amountintegerYesPoints to add (positive) or deduct (negative). A whole number, not 0, that fits a signed 32-bit integer.
reasonstringYesExplanation for the adjustment (max 500 characters)
idempotency_keystringYesUnique 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

CodeStatusDescription
CUSTOMER_NOT_FOUND404No customer found with the given ID
IDEMPOTENCY_KEY_CONFLICT422This idempotency_key was already used for a different amount
INSUFFICIENT_BALANCE422The adjustment would bring the balance below zero
WALLET_NOT_FOUND404The 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

ParameterTypeDescription
customerstringThe customer ID

Query parameters

ParameterTypeRequiredDescription
platformstringYesWhich 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

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

ParameterTypeDescription
customerstringThe customer ID

Request body

FieldTypeRequiredDescription
channelstringNoWhere 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

CodeStatusDescription
CARD_SEND_THROTTLED429This customer was already sent their card moments ago; wait details.retry_after seconds
CUSTOMER_DEACTIVATED422The customer left the programme, so no card is sent
CUSTOMER_HAS_NO_EMAIL422The customer has no email address on file, so there is nowhere to send the card
CUSTOMER_NOT_FOUND404No 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

ParameterTypeDescription
externalIdstringThe external_id you assigned this customer in your own system

Request body

FieldTypeRequiredDescription
channelstringNoWhere 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

CodeStatusDescription
CARD_SEND_THROTTLED429This customer was already sent their card moments ago; wait details.retry_after seconds
CUSTOMER_DEACTIVATED422The customer left the programme, so no card is sent
CUSTOMER_HAS_NO_EMAIL422The customer has no email address on file, so there is nowhere to send the card
EXTERNAL_ID_NOT_FOUND404No customer of yours has this external_id. A deactivated one is found and refused with CUSTOMER_DEACTIVATED instead