API reference

API Overview

The Puntjes API is a RESTful JSON API. All endpoints are scoped to the authenticated vendor and require OAuth2 bearer tokens.


Base URL

https://puntjes.app/api/v1

All endpoints documented in this reference are relative to this base URL.


Request format

  • Use Content-Type: application/json for request bodies
  • Include Authorization: Bearer {token} on every request (see Authentication)
  • All monetary amounts are in cents (e.g., 2500 = EUR 25.00)
  • All point values are integers

Response envelope

All successful responses are wrapped in a data envelope:

{
    "data": {
        "id": 1,
        "first_name": "Jan",
        "last_name": "De Vries"
    }
}

Paginated endpoints put the whole page inside that envelope, so the items sit two levels down, at data.data, next to data.links and data.meta. GET /rewards is the one list that is not paginated: there data is the bare array.

{
  "data": {
    "data": [...],
    "links": {
      "first": "...",
      "last": "...",
      "prev": null,
      "next": "..."
    },
    "meta": {
      "current_page": 1,
      "last_page": 5,
      "per_page": 15,
      "total": 72
    }
  }
}

Follow data.links.next as given. It carries the query parameters you sent (filters, sort order and per_page), so page 2 of a filtered result set is the same filter as page 1. Rebuilding the URL yourself from data.meta.current_page drops them: a walk that starts at per_page=5 silently returns 15 rows on the second request.


Error responses

Errors follow a consistent format:

{
    "error": {
        "code": "CUSTOMER_NOT_FOUND",
        "message": "No customer found with the given identifier.",
        "status": 404,
        "request_id": "550e8400-e29b-41d4-a716-446655440000"
    }
}

Validation errors (422) include a details field:

{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "The given data was invalid.",
        "status": 422,
        "request_id": "...",
        "details": {
            "email": ["The email field must be a valid email address."],
            "identifiers.0.type": ["The identifier type must be one of: email, loyalty_card."]
        }
    }
}

See Error handling for a full list of error codes.


Idempotency

Every endpoint that moves points requires an idempotency_key (max 255 characters). Repeating a request with a key you already used returns the original resource instead of processing it again, so these calls are safe to retry after a network failure or timeout:

EndpointKey is unique perA replay returns
POST /transactionsvendorThe original transaction; no points re-awarded
POST /redemptionsvendorThe original redemption; no points deducted again, stock untouched
POST /customers/{customer}/wallet/adjustcustomer's walletThe original ledger entry; balance not moved again

Use a value that identifies the operation in your own system (an order ID, or a UUID generated once per checkout and reused across retries). Random values that change on every attempt defeat the purpose.

This protects against network issues and duplicate submissions from POS systems.


Endpoint summary

MethodEndpointDescription
GET/meGet vendor branding
GET/customers/lookupLook up a customer
POST/customersRegister a customer
PUT PATCH/customers/by-external-id/{externalId}Update a customer
POST/customers/link-external-idLink an external ID
POST/customers/by-external-id/{externalId}/send-cardSend the loyalty card, keyed on your own id
POST/customers/{customer}/send-cardSend the customer their loyalty card
GET/customers/{customer}Get customer details
POST/transactionsSubmit a transaction
GET/customers/{customer}/walletGet wallet balance
GET/customers/{customer}/ledgerList ledger entries
GET/customers/{customer}/redemptionsList a customer's redemptions
GET/customers/{customer}/vouchersList a customer's open vouchers
GET/customers/{customer}/wallet-passDownload a wallet pass
GET/customers/{customer}/transactionsList customer transactions
POST/customers/{customer}/wallet/adjustAdjust wallet balance
GET/campaignsList campaigns
GET/branchesList active branches
GET/rewardsList rewards
POST/redemptionsRedeem a reward
GET/redemptions/{code}Look up a redemption
POST/redemptions/{code}/verifyVerify a redemption
GET/vouchers/{code}Look up a voucher
POST/vouchers/{code}/verifyVerify a voucher
GET/productsList products
POST/productsCreate a product
POST/products/batchBulk upsert products
POST/products/{externalId}/rewardCreate a reward from a product
GET/products/{externalId}Get a product
PUT/products/{externalId}Create or update a product (upsert)
PATCH/products/{externalId}Update a product
DELETE/products/{externalId}Delete a product

Get vendor branding

GET /api/v1/me

Return the branding of the vendor your token belongs to. Use it to colour a POS screen or a webshop widget the way the vendor's own customers already recognise, without hardcoding anything per client.

default_branch is the branch your API credential defaults to, as { "external_id", "name", "type" }. It is null when the credential has none. A branch that is deactivated is still named. A write that falls back to it can still refuse it: a new transaction at a deactivated branch answers BRANCH_INACTIVE. Read it to learn your own branch key instead of keeping a copy in your configuration.

Example request

curl https://puntjes.app/api/v1/me \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response

{
    "data": {
        "display_name": "Koffiebar De Hoek",
        "logo_url": "https://cdn.example.com/logo.png",
        "brand_color_primary": "#4F46E5",
        "brand_color_accent": "#06B6D4",
        "default_branch": {
            "external_id": "webshop",
            "name": "Webshop",
            "type": "online"
        }
    }
}

List active branches

GET /api/v1/branches

Return the active branches of your vendor, in the order you keep them. A deactivated branch is not listed. The list is not paginated: data is the bare array. Check the branch key you have configured against the external_id values here, or show the names to the cashier.

Example request

curl https://puntjes.app/api/v1/branches \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response

{
    "data": [
        {
            "external_id": "webshop",
            "name": "Webshop",
            "type": "online"
        }
    ]
}