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

ParameterTypeRequiredDefaultDescription
statusstringNo—Filter by status: active, inactive, or archived
categorystringNo—Filter by exact category
searchstringNo—Case-insensitive match on the product name
per_pageintegerNo—Page size, 1–100 (default: 15)
pageintegerNo1Which 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

FieldTypeRequiredDescription
external_idstringYesYour own product identifier (SKU/PLU), unique per vendor. One that holds / cannot be used in a /products/{externalId} path
namestringYesProduct name
descriptionstringNoProduct description
price_centsintegerNoPrice in cents (e.g. 350 = EUR 3.50)
image_urlstringNoAbsolute URL to the product image, http or https only
categorystringNoFree-form category label
stockintegerNoAvailable stock (omit for untracked)
statusstringNoactive, inactive, or archived (default: active)
metadataobjectNoArbitrary 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

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

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

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

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

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

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

FieldTypeRequiredDescription
productsarrayYes1–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

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

ParameterTypeDescription
externalIdstringYour product identifier (SKU/PLU). One that holds / cannot be used in a /products/{externalId} path

Request body

FieldTypeRequiredDescription
point_costintegerYesPoints required to redeem (minimum 1)
namestringNoOverride the reward name (default: product name)
descriptionstringNoOverride the reward description (default: product description)
total_stockintegerNoReward stock (omit for unlimited)
statusstringNoactive or inactive (default: active)
available_fromstringNoStart date (YYYY-MM-DD)
available_untilstringNoEnd date (YYYY-MM-DD), on or after available_from
code_valid_for_hoursintegerNoRedemption code validity in hours, from 1 to 87600 (ten years). A larger value answers 422 VALIDATION_ERROR
payment_amountintegerNoAmount the till collects on top of the points, in cents (0 = points only)
idempotency_keystringNoOptional. 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_customerintegerNoHow 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

CodeStatusDescription
IDEMPOTENCY_KEY_CONFLICT422This idempotency_key was already used for another product or another amount, or its reward was deleted
PRODUCT_NOT_FOUND404No product with this external_id for the vendor