API reference
Product endpoints
Endpoints for syncing your product catalogue into Puntjes and turning products into redeemable rewards.
Products are keyed on your own external id (the SKU/PLU from your POS or webshop), which is unique per vendor. Prices are expressed in cents. Use these endpoints to push your catalogue and then offer any product as a "free product" reward.
An external_id that holds / cannot be used in a path
Puntjes stores an external_id with a / in it, but no /products/{externalId} route can reach that product. The
path answers 404 ROUTE_NOT_FOUND, or 405 METHOD_NOT_ALLOWED when it happens to match another route, such as PUT /products/A%2Freward. Sending the / as %2F does not help. Read such a product with GET /products and change
it with POST /products/batch.
Idempotent sync
For catalogue synchronisation, prefer PUT /products/{external_id} (single) or POST /products/batch (many): both
create the product if it does not exist yet and update it otherwise, so pushing the same catalogue repeatedly
converges on the same rows.
List products
GET /api/v1/products
Return the vendor's products, paginated and ordered by name the way a phone book is: capitals and
accents do not move a name, so de Smet sits next to Dhondt. Products with the same name keep one
order on every page.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
status | string | No | — | Filter by status: active, inactive, or archived |
category | string | No | — | Filter by exact category |
search | string | No | — | Case-insensitive match on the product name |
per_page | integer | No | — | Page size, 1–100 (default: 15) |
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,
"external_id": "SKU-1001",
"name": "Cappuccino",
"description": "A warm cup",
"price_cents": 350,
"image_url": "https://cdn.example.com/cappuccino.png",
"category": "drinks",
"stock": 20,
"status": "active",
"metadata": {
"barcode": "54000012"
},
"created_at": "2026-07-18T10:00:00+00:00",
"updated_at": "2026-07-18T10:00:00+00:00"
}
],
"links": {
"first": "https://puntjes.app/api/v1/products?page=1",
"last": "https://puntjes.app/api/v1/products?page=1",
"prev": null,
"next": null
},
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 15,
"total": 1
}
}
}
Create a product
POST /api/v1/products
Create a single product. The external_id must be unique within your vendor; use PUT for idempotent create-or-update.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | Yes | Your own product identifier (SKU/PLU), unique per vendor. One that holds / cannot be used in a /products/{externalId} path |
name | string | Yes | Product name |
description | string | No | Product description |
price_cents | integer | No | Price in cents (e.g. 350 = EUR 3.50) |
image_url | string | No | Absolute URL to the product image, http or https only |
category | string | No | Free-form category label |
stock | integer | No | Available stock (omit for untracked) |
status | string | No | active, inactive, or archived (default: active) |
metadata | object | No | Arbitrary key/value attributes carried through untouched, at most 16384 bytes of UTF-8 in their compact JSON form |
Example request
{
"external_id": "SKU-1001",
"name": "Cappuccino",
"description": "A warm cup",
"price_cents": 350,
"image_url": "https://cdn.example.com/cappuccino.png",
"category": "drinks",
"stock": 20,
"status": "active",
"metadata": {
"barcode": "54000012"
}
}
curl -X POST https://puntjes.app/api/v1/products \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"external_id":"SKU-1001","name":"Cappuccino","description":"A warm cup","price_cents":350,"image_url":"https://cdn.example.com/cappuccino.png","category":"drinks","stock":20,"status":"active","metadata":{"barcode":"54000012"}}'
Response (201 Created)
{
"data": {
"id": 1,
"external_id": "SKU-1001",
"name": "Cappuccino",
"description": "A warm cup",
"price_cents": 350,
"image_url": "https://cdn.example.com/cappuccino.png",
"category": "drinks",
"stock": 20,
"status": "active",
"metadata": {
"barcode": "54000012"
},
"created_at": "2026-07-18T10:00:00+00:00",
"updated_at": "2026-07-18T10:00:00+00:00"
}
}
Errors
| Code | Status | Description |
|---|---|---|
PRODUCT_EXTERNAL_ID_DUPLICATE | 409 | A product with this external_id already exists for the vendor |
Create or update a product (upsert)
PUT /api/v1/products/{externalId}
Idempotently create the product if it does not exist, or replace it if it does. The external_id is taken from the URL, so it is not part of the body. Omitted optional fields are cleared. Send the full product representation each time. This is the primary catalogue-sync primitive.
Path parameters
| Parameter | Type | Description |
|---|---|---|
externalId | string | Your product identifier (SKU/PLU). One that holds / cannot be used in a /products/{externalId} path |
Request body
Same fields as Create a product, excluding external_id. name is required.
Example request
{
"name": "Cappuccino",
"description": "A warm cup",
"price_cents": 350,
"image_url": "https://cdn.example.com/cappuccino.png",
"category": "drinks",
"stock": 20,
"status": "active",
"metadata": {
"barcode": "54000012"
}
}
curl -X PUT https://puntjes.app/api/v1/products/SKU-1001 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Cappuccino","description":"A warm cup","price_cents":350,"image_url":"https://cdn.example.com/cappuccino.png","category":"drinks","stock":20,"status":"active","metadata":{"barcode":"54000012"}}'
Response
Returns 201 Created when a new product was created, or 200 OK when an existing product was updated. The body is the product object (same shape as Create).
Update a product
PATCH /api/v1/products/{externalId}
Partially update a product. Only the fields present in the body are changed.
Path parameters
| Parameter | Type | Description |
|---|---|---|
externalId | string | Your product identifier (SKU/PLU). One that holds / cannot be used in a /products/{externalId} path |
Request body
Any subset of the Create a product fields, excluding external_id (it is immutable).
Example request
{
"name": "Cappuccino",
"description": "A warm cup",
"price_cents": 350,
"image_url": "https://cdn.example.com/cappuccino.png",
"category": "drinks",
"stock": 20,
"status": "active",
"metadata": {
"barcode": "54000012"
}
}
curl -X PATCH https://puntjes.app/api/v1/products/SKU-1001 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Cappuccino","description":"A warm cup","price_cents":350,"image_url":"https://cdn.example.com/cappuccino.png","category":"drinks","stock":20,"status":"active","metadata":{"barcode":"54000012"}}'
Errors
| Code | Status | Description |
|---|---|---|
PRODUCT_NOT_FOUND | 404 | No product with this external_id for the vendor |
Get a product
GET /api/v1/products/{externalId}
Fetch a single product by its external id.
Response
{
"data": {
"id": 1,
"external_id": "SKU-1001",
"name": "Cappuccino",
"description": "A warm cup",
"price_cents": 350,
"image_url": "https://cdn.example.com/cappuccino.png",
"category": "drinks",
"stock": 20,
"status": "active",
"metadata": {
"barcode": "54000012"
},
"created_at": "2026-07-18T10:00:00+00:00",
"updated_at": "2026-07-18T10:00:00+00:00"
}
}
Errors
| Code | Status | Description |
|---|---|---|
PRODUCT_NOT_FOUND | 404 | No product with this external_id for the vendor |
Delete a product
DELETE /api/v1/products/{externalId}
Soft-delete a product. It no longer appears in the catalogue; rewards already created from it keep working. Re-pushing the same external_id via PUT restores it.
Response
204 No Content on success.
Errors
| Code | Status | Description |
|---|---|---|
PRODUCT_NOT_FOUND | 404 | No product with this external_id for the vendor |
Bulk upsert products
POST /api/v1/products/batch
Upsert up to 100 products in a single call, which is how you sync a whole catalogue. Each item is processed independently: one bad item comes back with status error in its own result and does not fail the rest of the batch. The call always returns 200 OK with a per-item results array and a summary.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
products | array | Yes | 1–100 product objects, each with an external_id and the Create a product fields |
Example request
{
"products": [
{
"external_id": "SKU-1001",
"name": "Cappuccino",
"description": "A warm cup",
"price_cents": 350,
"image_url": "https://cdn.example.com/cappuccino.png",
"category": "drinks",
"stock": 20,
"status": "active",
"metadata": {
"barcode": "54000012"
}
}
]
}
curl -X POST https://puntjes.app/api/v1/products/batch \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"products":[{"external_id":"SKU-1001","name":"Cappuccino","description":"A warm cup","price_cents":350,"image_url":"https://cdn.example.com/cappuccino.png","category":"drinks","stock":20,"status":"active","metadata":{"barcode":"54000012"}}]}'
Response
{
"data": {
"results": [
{
"index": 0,
"external_id": "SKU-1001",
"status": "created",
"product": {
"id": 1,
"external_id": "SKU-1001",
"name": "Cappuccino",
"description": "A warm cup",
"price_cents": 350,
"image_url": "https://cdn.example.com/cappuccino.png",
"category": "drinks",
"stock": 20,
"status": "active",
"metadata": {
"barcode": "54000012"
},
"created_at": "2026-07-18T10:00:00+00:00",
"updated_at": "2026-07-18T10:00:00+00:00"
},
"errors": {}
}
],
"summary": {
"total": 2,
"created": 1,
"updated": 1,
"failed": 0
}
}
}
Each result's status is created, updated, or error. The example above lists every key a result can carry; errors is present only on an error item, and describes the fields that failed. An external_id that appears more than once in one batch is applied once: the first item with it is processed, and every later one comes back as error, with the index of that first item in errors.external_id.
Errors
| Code | Status | Description |
|---|---|---|
BATCH_TOO_LARGE | 422 | The batch contains more than 100 products |
Create a reward from a product
POST /api/v1/products/{externalId}/reward
Turn a product into a redeemable free product reward. The reward defaults its name and description to the product's own (both overridable) and is linked back to the product. Redemption then works through the standard reward and redemption endpoints.
Path parameters
| Parameter | Type | Description |
|---|---|---|
externalId | string | Your product identifier (SKU/PLU). One that holds / cannot be used in a /products/{externalId} path |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
point_cost | integer | Yes | Points required to redeem (minimum 1) |
name | string | No | Override the reward name (default: product name) |
description | string | No | Override the reward description (default: product description) |
total_stock | integer | No | Reward stock (omit for unlimited) |
status | string | No | active or inactive (default: active) |
available_from | string | No | Start date (YYYY-MM-DD) |
available_until | string | No | End date (YYYY-MM-DD), on or after available_from |
code_valid_for_hours | integer | No | Redemption code validity in hours, from 1 to 87600 (ten years). A larger value answers 422 VALIDATION_ERROR |
payment_amount | integer | No | Amount the till collects on top of the points, in cents (0 = points only) |
idempotency_key | string | No | Optional. Your own key for this reward, at most 255 characters and unique per vendor. A retry is the same key with the same product, point_cost and payment_amount; the name, text, stock and dates may differ and the first reward is returned as it is now |
max_redemptions_per_customer | integer | No | How many times one customer can redeem this reward, at least 1 (omit for no limit) |
Example request
{
"point_cost": 150,
"name": "Cappuccino",
"description": "A warm cup",
"total_stock": 50,
"status": "active",
"available_from": null,
"available_until": null,
"code_valid_for_hours": null,
"payment_amount": 200,
"idempotency_key": "reward-sku-1001",
"max_redemptions_per_customer": 2
}
curl -X POST https://puntjes.app/api/v1/products/SKU-1001/reward \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"point_cost":150,"name":"Cappuccino","description":"A warm cup","total_stock":50,"status":"active","available_from":null,"available_until":null,"code_valid_for_hours":null,"payment_amount":200,"idempotency_key":"reward-sku-1001","max_redemptions_per_customer":2}'
Response (201 Created)
{
"data": {
"id": 1,
"product_id": 1,
"name": "Cappuccino",
"description": "A warm cup",
"type": "free_product",
"point_cost": 150,
"image_url": "https://cdn.example.com/cappuccino.png",
"total_stock": 50,
"remaining_stock": 50,
"status": {
"value": "active",
"label": "Active"
},
"available_from": null,
"available_until": null,
"discount_value": null,
"discount_type": null,
"product_reference": "SKU-1001",
"payment_amount": 200,
"code_valid_for_hours": null,
"created_at": "2026-07-18T10:00:00+00:00",
"updated_at": "2026-07-18T10:00:00+00:00",
"is_unlimited": false,
"max_redemptions_per_customer": 2
}
}
is_unlimited is true when the reward has no stock limit; remaining_stock is then 0.
status.label is always English
The reward comes back with status as { "value": "active", "label": "Active" }. There is no request
locale on this API and no server-side translation, so label carries the English word whichever language
your integration speaks. Map value yourself. It is active or inactive.
Send an idempotency_key to make a retry safe. A second call with the same key, the same
product, the same point_cost and the same payment_amount answers 201 with the reward the
first call made, and creates nothing. The name, the description, the stock and the dates may
differ. The same key with another product or another amount answers 422
IDEMPOTENCY_KEY_CONFLICT, and so does a key whose reward you deleted in the admin portal. A
retry after the product was deleted answers 404 PRODUCT_NOT_FOUND. Without a key, or with an
empty one, every call creates a new reward.
Errors
| Code | Status | Description |
|---|---|---|
IDEMPOTENCY_KEY_CONFLICT | 422 | This idempotency_key was already used for another product or another amount, or its reward was deleted |
PRODUCT_NOT_FOUND | 404 | No product with this external_id for the vendor |