Running a promotion
A discount with a name and, optionally, a window. While it runs, every new offer is created carrying its rate and its wording.
Reading takes promotions:read and writing promotions:write, and neither implies the other.
Its terms are stamped onto an offer when the offer is created, and the offer keeps that snapshot for good. So ending a promotion, rewording it or deleting it outright never changes a price a customer was already quoted — which is also why none of these writes touch an offer that already exists.
Starting one#
POST /promotions{ "name": "Najaarsactie", "code": "autumn2026", "discount_pct": 10, "description": "10% najaarskorting", // the line the customer reads on the offer "valid_from": "2026-09-01T00:00:00+02:00", "valid_until": "2026-09-30T23:59:59+02:00"}// -> 201// {// "data": {// "id": 41, "name": "Najaarsactie", "code": "autumn2026",// "discount_pct": 10, "is_active": true,// "is_live": true,// "is_applied_to_new_offers": true,// "valid_from": "2026-08-31T22:00:00+00:00",// "valid_until": "2026-09-30T21:59:59+00:00",// "backfill_preview": { "eligible": 12, "applied": 0,// "skipped_pinned": 1, "skipped_dealer_set": 3 }// }// }Send window edges with an offset. Both ends are inclusive and either may be null, which means open-ended. A timestamp without an offset is read as Dutch local time rather than UTC.
The two computed flags#
They come back computed because re-deriving them from the window in your own code is how a dashboard ends up disagreeing with what we actually quote. is_live is enabled and inside its window, by our clock. is_applied_to_new_offers is the stronger one: several promotions can be live at once, and exactly one of them — the newest — is the one new offers get.
Writes also carry also_live: the other promotions running right now, newest first. Saving a live promotion displaces whichever was winning before, so an empty also_live is the reassuring answer and a non-empty one tells you which promotion just stopped biting.
Changing and ending one#
PATCH is partial: send only what moves, and 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.
To stop a promotion but keep the record, send {"is_active": false}. That is almost always what you want: DELETE removes the row, and while the offers carrying it keep their discount and their wording, what does not survive is knowing which promotion won them — after the delete they are indistinguishable from offers discounted by hand.
DELETE /promotions/41// -> 409// { "meta": { "message": "12 offers carry this promotion. …" },// "errors": { "code": "promotion_in_use", "offers_count": 12, "is_live": true } }DELETE /promotions/41?force=1// -> { "data": { "id": 41, "deleted": true, "offers_keeping_their_discount": 12 } }Spreading one over the pipeline#
Offers already in the pipeline are not swept up by a save. Applying is a separate, deliberate call — and it reaches only concept offers that were never sent, never one priced by hand, and never one whose total was pinned. Run it twice and the second run does nothing.
POST /promotions/41/apply { "dry_run": true }// -> { "data": { "dry_run": true, "promotion_id": 41,// "eligible": 12, // concept offers this would discount// "applied": 0,// "skipped_pinned": 1, // total pinned to the cent by the dealer// "skipped_dealer_set": 3 } }// a negotiated rate, or a promotion removed by hand// Same call without dry_run applies it: "applied": 12.Applying a promotion whose rate is 0 returns 422 nothing_to_apply. A discount of zero is how we record that a promotion was taken off an offer by hand, so applying one in bulk would mark that whole batch as deliberately un-promoted and keep the live promotion off them.
Field-by-field parameters are on the reference page.