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

CodeStatusDescription
CUSTOMER_NOT_FOUND404No customer found with the given ID
WALLET_NOT_FOUND404The 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

ParameterTypeDescription
customerstringThe customer ID

Query parameters

ParameterTypeRequiredDefaultDescription
date_fromstringNo—Only entries created at or after this point. Compared against created_at, so a bare date starts at midnight UTC
date_tostringNo—Only entries created at or before this point. A bare date therefore stops at midnight, excluding that day
typestringNo—One of earn, adjust, redeem or expire
pageintegerNo1Which 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

TypeAmount signDescription
earnPositivePoints earned from a transaction
adjustPositive or negativeManual balance adjustment
redeemNegativePoints spent on a reward
expireNegativePoints expired

Errors

CodeStatusDescription
CUSTOMER_NOT_FOUND404No customer found with the given ID
WALLET_NOT_FOUND404The 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

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": "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

CodeStatusDescription
CUSTOMER_DEACTIVATED422The 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_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.

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

CodeStatusDescription
APPLE_WALLET_UNAVAILABLE503Apple has not finished the shop's Apple identity yet, so no Apple pass can be signed. Retry after the seconds in Retry-After
CUSTOMER_DEACTIVATED422The 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_FOUND404No 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

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
    }
}

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

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_EMAIL_SUPPRESSED422Mail 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_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. One that holds / cannot be used here

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_EMAIL_SUPPRESSED422Mail 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_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