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

ParameterTypeRequiredDescription
statusstringNoFilter by status: active, inactive, or archived
categorystringNoFilter by exact category
searchstringNoCase-insensitive match on the product name
per_pageintegerNoPage 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

FieldTypeRequiredDescription
external_idstringYesYour own product identifier (SKU/PLU), unique per vendor
namestringYesProduct name
descriptionstringNoProduct description
price_centsintegerNoPrice in cents (e.g. 350 = EUR 3.50)
image_urlstringNoAbsolute URL to the product image
categorystringNoFree-form category label
stockintegerNoAvailable stock (omit for untracked)
statusstringNoactive, inactive, or archived (default: active)
metadataobjectNoArbitrary 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

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)

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)

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.

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

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)

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

CodeStatusDescription
PRODUCT_NOT_FOUND404No product with this external_id for the vendor