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.
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.
Query parameters
| Parameter | Type | Required | 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) |
Paginate with ?page=, and size the page with ?per_page= (1–100, default 15). page is
read from the query string by the paginator itself, so it never appears in the parameter
table above.
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 |
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 |
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 |
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) |
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) |
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.
Example request
curl -X POST https://puntjes.app/api/v1/products/batch \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Response
{
"data": {
"results": [
{
"external_id": "SKU-1001",
"index": 0,
"product.external_id": "SKU-1001",
"product.id": 1,
"product.name": "Cappuccino",
"product.price_cents": 350,
"product.status": "active",
"status": "created"
}
],
"summary": {
"total": 2,
"created": 1,
"updated": 1,
"failed": 0
}
}
}
Each result's status is created, updated, or error. Items with error include an errors object describing the failed fields.
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) |
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) |
code_valid_for_hours | integer | No | Redemption code validity in hours |
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
}
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}'
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",
"code_valid_for_hours": null,
"created_at": "2026-07-18T10:00:00+00:00",
"updated_at": "2026-07-18T10:00:00+00:00"
}
}
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.
Errors
| Code | Status | Description |
|---|---|---|
PRODUCT_NOT_FOUND | 404 | No product with this external_id for the vendor |