Zinevu
API reference

Promotions

A discount with a name and, optionally, a window. Reading takes promotions:read, writing promotions:write, and neither implies the other.

The behaviour behind these endpoints — what a promotion does to an offer, and when — is on the guide. This page is the field-by-field.

GET/promotions

promotions:read

Your promotions, and which one new offers get.

Parameters
active_only
boolean
Only promotions that are switched on.
code
string
Exact match on your own code.
updated_since
ISO 8601
Only records changed at or after this instant.
limit
integer, 1–100
Defaults to 25.
cursor
string
From meta.next_cursor.
Request
curl "https://api.zinevu.com/api/public/v1/promotions?active_only=1" \  -H "Authorization: Bearer $ZINEVU_API_KEY"
Response
{  "data": [    {      "id": 41,      "name": "Najaarsactie",      "code": "autumn2026",      "discount_pct": 10,      "description": "10% najaarskorting",      "valid_from": "2026-08-31T22:00:00+00:00",      "valid_until": "2026-09-30T21:59:59+00:00",      "is_active": true,      "is_live": true,      "is_applied_to_new_offers": true,      "created_at": "2026-08-28T12:02:11+00:00",      "updated_at": "2026-08-28T12:02:11+00:00"    }  ]}

GET/promotions/{id}

promotions:read

One promotion, with the number of offers carrying it.

POST/promotions

promotions:write

Start a promotion. Applies to new offers, never to offers that exist.

Parameters
namerequired
string, ≤255
What it is called in the portal.
discount_pctrequired
number, 0–100
The rate. Rounded to two decimals.
code
string, ≤80 | null
Your own identifier for it.
description
string, ≤5000 | null
The line the customer reads on the offer.
valid_from / valid_until
ISO 8601 | null
Both ends inclusive; null means open-ended. Send an offset — without one it is read as Dutch local time.
is_active
boolean
Defaults to true.
Response
// -> 201{  "data": {    "id": 41,    "is_live": true,    "is_applied_to_new_offers": true,    "backfill_preview": { "eligible": 12, "applied": 0, "skipped_pinned": 1, "skipped_dealer_set": 3 },    "also_live": []  }}

PATCH/promotions/{id}

promotions:write

Change one — partial, so send only what moves.

Everything you leave out keeps its stored value; null is a real value and clears the field. The window is checked against the row as it will be, so moving one edge past the other is refused even when the other one is not in your payload.

DELETE/promotions/{id}

promotions:write

End one. Refused once if offers carry it; they keep their discount either way.

Parameters
force
boolean
Repeat the delete with ?force=1 to go ahead.
Response
// -> 409 the first time{  "meta": { "message": "12 offers carry this promotion. …" },  "errors": { "code": "promotion_in_use", "offers_count": 12, "is_live": true }}// -> 200 with ?force=1{ "data": { "id": 41, "deleted": true, "offers_keeping_their_discount": 12 } }
Prefer switching it off

{"is_active": false} keeps the record, and with it the knowledge of which promotion won those offers. After a delete they are indistinguishable from offers discounted by hand.

POST/promotions/{id}/apply

promotions:write

Spread one over the offers still open. Supports dry_run.

Parameters
dry_run
boolean
Report what would change without changing it.
Response
{  "data": {    "dry_run": true,    "promotion_id": 41,    "eligible": 12,    "applied": 0,    "skipped_pinned": 1,    "skipped_dealer_set": 3  }}

The computed flags#

FieldMeans
is_activeThe switch. Stored, and yours to set.
is_liveActive and inside its window, by our clock.
is_applied_to_new_offersSeveral can be live at once; exactly one — the newest — is the one new offers get.
also_liveOn a write: the other promotions running right now. Empty is the reassuring answer.
backfill_previewOn a write: what an apply would reach, without asking twice.