Promotions allow you to offer discounts, credits, and special pricing to customers through coupon codes, automatic rules, or manual application.
Key concepts:
- Code - the coupon code a customer enters (e.g.,
SAVE20) - Effects - what the promotion does: percentage discount, fixed discount, free trial extension, or credit grant
- Conditions - eligibility rules: minimum spend, specific plans, customer segments, date ranges
- Budget - optional spending cap; the promotion deactivates when the budget is exhausted
- Stackable - whether this promotion can combine with other active promotions
- Simulation - preview the financial impact of a promotion before activating it
What this customer holds
Every promotion the customer still holds, what each has saved them, and what is left of the ceilings that apply to THEM — a per-customer override replaces the promotion-wide row of the same shape, and this read resolves that the same way the next bill will. used is the largest single bucket total the customer contributes to and never the sum across buckets, because a ceiling applies per bucket; for the lifetime ceilings a customer usually has there is only one bucket, so the figure is exact. Holds that ended are not listed: a page headed "their promotions" must not name one they no longer have. Each row is the redemption; include= adds saved, promotion, remaining_limits and until, and all four are sent without include= until 2026-11-30, after that only the ones named.
Required scope: promotion.read (Read promotions).
path Parameters
idCustomer UUID
query Parameters
includeExtra data to add to the response: saved, promotion, remaining_limits, until. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. saved is sent without being asked until 2026-11-30; after that date it is sent only when named here. promotion is sent without being asked until 2026-11-30; after that date it is sent only when named here. remaining_limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. until is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
What this customer holds › Responses
OK
What promotions did to an invoice
Every promotion row of this invoice, draft or issued, in lane order, and what they took off it per currency. On a draft the rows are PENDING (reserved, taken when it is issued); once issued they are SETTLED. Rewards paid back later against its finalised lines are listed too and are not in saved. Read from the firing ledger — the one table money moved through — and not from a record kept beside the invoice: the old domain kept both and the two could disagree. One request per invoice; match a row to its lines by product and price_key.
From 2026-11-30 this list answers {data:[...], pagination}: the firings as the list, without saved.
Required scope: promotion.read (Read promotions).
path Parameters
idInvoice UUID
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
What promotions did to an invoice › Responses
OK
List promotions
The organization's promotions, newest first, keyset-paginated, filterable by status, shape and distribution. Each row is the promotion's own fields; the parts (shape slots, scope, never-with set, ceilings, customer lists, translations) are sent only when named in include=, because each is a child-table read per row. Ordering is by created_at alone — name is display text a reader may see translated, and status and window_end both MOVE while a promotion is live, so a walk over either can skip or repeat rows.
Until 2026-11-30 a row's shape is the shape's kind without include=shape (the same value as shape_kind); from then the list answers Promotion, and shape is sent only with include=shape, as the object. include=effect is deprecated: name shape, scope and never_with instead.
Required scope: promotion.read (Read promotions).
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations, effect. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400.
limitPage size (default 50, max 200)
cursorOpaque continuation token from the previous response's pagination.cursor. Omit for the first page. Cursors are stateless and do not expire, but are only valid for the list and filters that produced them.
sortOrdering: created_at with an optional :asc/:desc suffix (default created_at:desc). An unknown value is rejected. Cursors are bound to the ordering that issued them, so changing sort mid-walk needs a fresh first page.
statusFilter by lifecycle status.
shapeFilter by shape.
distributionFilter by how customers come to hold it.
searchCase- and accent-insensitive substring match over the name and the coupon code. Combines with the other filters (AND) and does not change the ordering.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
List promotions › Responses
OK
Create a draft promotion
Writes the promotion whole — header, shape row, scope, ceilings — as a DRAFT. There is no create-and-publish: publishing is what freezes the rule, and it is its own act. The shape is a discriminated union, so an unknown kind or a slot that belongs to another kind is refused here and never at bill time.
Required scope: promotion.write (Write promotions).
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Create a draft promotion › Request Body
distributionHow a customer comes to hold it. Frozen once published.
nameThe operator's name for the offer.
The shape and its slots, flattened under a kind discriminator. Frozen once the promotion is published.
window_startFirst day the promotion may fire, inclusive. Frozen once published; a backdated start is allowed and re-bills nothing, because the ledger starts at publish.
audience_idA library audience to reference. Editing a shared audience affects every promotion using it; fork it for a private one.
codeThe coupon code. Required for a coupon promotion and refused for any other. Unique within the workspace, case-insensitively.
customer_excludesCustomers refused regardless of the audience. A customer on both lists is refused as an authoring error rather than resolved.
customer_includesCustomers admitted regardless of the audience.
descriptionInternal note. Never shown to a customer.
The ceilings, in authoring order. Editable while live, downwards.
negotiated_pricesWhether this promotion may cut a hand-negotiated price. Defaults to exclude. Frozen once published; an audience-targeted limit row may override it.
never_withPromotions this one may never fire alongside. Enforced across all four lanes; inside the price-cut lane the later-published side loses. Frozen once published.
on_narrowingWhat happens to existing holders when the promotion is narrowed under them. Defaults to keep, and stays editable while live.
public_descriptionWhat the customer is told the offer is.
What the promotion applies to. Omit for the whole catalogue. Frozen once published.
succeedsThe promotion this one replaces. A lineage pointer only: no redemption is migrated.
Locale-keyed overrides for name and public_description, keyed by language code (en, de, fr, es, it, pt, nl, ja). The internal description is not translatable.
window_endLast day it may fire, inclusive. Omit for open-ended. This is the one window bound a live promotion may still move, and only inwards.
Create a draft promotion › Responses
Created
Explain what a customer's promotions would do to a bill
Runs the customer's held promotions against the lines you send and returns each line's net plus the chain that produced it: the four lanes in order, and every step in them — INCLUDING the NON-FIRINGS, a promotion that matched a line and did nothing to it with the reason why. It writes nothing and it is the same loader and the same arithmetic a real bill runs, so a preview and the invoice that follows it cannot disagree.
The bill is yours to state. This endpoint deliberately does NOT assemble one from a subscription and a period: turning a subscription's catalogue into priced lines is the invoice pipeline's, and its translation into these lines already exists exactly once. Pass subscription_id to narrow which redemptions may fire (an anchored one fires only on its own subscription's lines) and period_start / period_end to say which period the ceilings bucket by. A period that ends before it starts is refused: it describes no bill there could be, and evaluating it would report an undiscounted bill as the right answer.
Send promotion — the body of POST /promotions — to evaluate an UNPUBLISHED DRAFT alongside the promotions the customer already holds, as if it had just been published. It takes part in the lanes like any other, the customer is treated as holding it (a draft has no holders, and without that it could only ever answer "nothing applied"), and nothing is written. The response returns the id it was evaluated under as draft_promotion_id, which is how its steps are told from the live ones in the chain.
Required scope: promotion.read (Read promotions).
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Explain what a customer's promotions would do to a bill › Request Body
The bill to evaluate.
period_endThe bill's period end, inclusive.
period_startThe bill's period start, inclusive.
atThe billing instant the ceilings are counted as of. Defaults to now on the caller's clock, which in a sandbox is the workspace's virtual calendar.
customer_idWhose held promotions are evaluated. Optional when a promotion draft is supplied: with no customer the draft is evaluated ALONE, as if published and held by nobody (no live promotions, no redemption history, no audience membership), and the response says so with draft_only.
An unpublished draft to evaluate as if it were live, alongside the customer's held promotions. Nothing is persisted.
subscription_idNarrows which redemptions may fire to the unanchored ones and the ones anchored here.
Explain what a customer's promotions would do to a bill › Responses
OK
Get one promotion
The promotion's own fields and the parts named in include=: the shape with its slots flattened under a kind discriminator, the scope, the never-with set, the ceilings in authoring order, the explicit customer lists and the translations. All six are sent without include= until 2026-11-30; after that only the ones named.
Required scope: promotion.read (Read promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Get one promotion › Responses
OK
Rewrite a draft promotion
Replaces the draft whole. A draft freezes nothing, so the body is the entire promotion and not a patch — with six optional sets, an absent key would have to mean "leave it" for some and "empty it" for others. A promotion that is no longer a draft is refused with PROMOTION_NOT_DRAFT: narrow it instead, or end it and publish a successor.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Rewrite a draft promotion › Request Body
distributionHow a customer comes to hold it. Frozen once published.
nameThe operator's name for the offer.
The shape and its slots, flattened under a kind discriminator. Frozen once the promotion is published.
window_startFirst day the promotion may fire, inclusive. Frozen once published; a backdated start is allowed and re-bills nothing, because the ledger starts at publish.
audience_idA library audience to reference. Editing a shared audience affects every promotion using it; fork it for a private one.
codeThe coupon code. Required for a coupon promotion and refused for any other. Unique within the workspace, case-insensitively.
customer_excludesCustomers refused regardless of the audience. A customer on both lists is refused as an authoring error rather than resolved.
customer_includesCustomers admitted regardless of the audience.
descriptionInternal note. Never shown to a customer.
The ceilings, in authoring order. Editable while live, downwards.
negotiated_pricesWhether this promotion may cut a hand-negotiated price. Defaults to exclude. Frozen once published; an audience-targeted limit row may override it.
never_withPromotions this one may never fire alongside. Enforced across all four lanes; inside the price-cut lane the later-published side loses. Frozen once published.
on_narrowingWhat happens to existing holders when the promotion is narrowed under them. Defaults to keep, and stays editable while live.
public_descriptionWhat the customer is told the offer is.
What the promotion applies to. Omit for the whole catalogue. Frozen once published.
succeedsThe promotion this one replaces. A lineage pointer only: no redemption is migrated.
Locale-keyed overrides for name and public_description, keyed by language code (en, de, fr, es, it, pt, nl, ja). The internal description is not translatable.
window_endLast day it may fire, inclusive. Omit for open-ended. This is the one window bound a live promotion may still move, and only inwards.
Rewrite a draft promotion › Responses
OK
Give the promotion a private audience
Copies the referenced library audience into one owned by this promotion and hidden from the library. It is the ONLY way to a private audience — everything else references the shared library, where an edit affects every promotion using it. Legal on a draft and on a live promotion: narrowing an audience to a private copy of itself removes nobody, so no on_narrowing is applied. Refused with 409 when the promotion already has a forked audience, and with 503 in a process that composed no forker.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Give the promotion a private audience › Responses
OK
End the promotion now
Ends it and, for every shape that promised no COUNT, ends its holders with it — free periods with periods left and a lifetime free-units allowance keep firing, because those promised a number and a per-bill cut promises nothing beyond the bill. Ending is terminal and idempotent: a promotion already ended comes back as it stands, because the timer, the backstop sweep, a promotion-wide ceiling and an operator all drive this same transition and the loser of that race has not failed.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
End the promotion now › Request Body
reasonWhy it ended. Defaults to manual, which is what an operator ending it now means; the other two are the system reaching a boundary the operator set.
End the promotion now › Responses
OK
Read the promotion's ledger
Every row this promotion wrote before money moved, filterable by customer, price key, status and bill period. Ordered by occurred_at — the BILLING clock of the bill that produced the row, never when the row was written: a firing written today for a period that closed last week occurred last week, and ordering by insertion would interleave a late-generated bill among this week's. A clawback is a NEW row with the opposite sign; nothing in this ledger is ever rewritten.
Required scope: promotion.read (Read promotions).
path Parameters
idPromotion UUID
query Parameters
limitPage size (default 50, max 200)
cursorOpaque continuation token from the previous response's pagination.cursor. Omit for the first page. Cursors are stateless and do not expire, but are only valid for the list and filters that produced them.
sortOrdering: occurred_at with an optional :asc/:desc suffix (default occurred_at:desc). occurred_at is the BILLING clock of the row, never when it was written.
customer_idOnly this customer's firings.
price_keyOnly firings on this price key.
statusOnly firings in this state.
period_startOnly firings whose bill period OVERLAPS the range, so a period straddling the boundary is included. A firing with no period (a hold before capture) is outside every period filter.
period_endThe other end of the period range.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Read the promotion's ledger › Responses
OK
How much of each ceiling is spent
Every authored ceiling of this promotion with what it has used and what is left, across all holders. used is the LARGEST SINGLE BUCKET of the row and never the sum across buckets: a ceiling applies per bucket, so a per-customer cap of 10 spent by fifty customers has used 10 and not 500, and the sum would read as five times over a limit that was never breached. It is the same figure, from the same function, that refuses a ceiling lowered below what is already spent, so this read and that refusal cannot disagree.
Rows bound to a customer are reported in their own right, each against that customer's bucket; a promotion-wide row some customer overrides carries overridden_for_some_customer. Everything is counted at read time from the firing ledger — or, for a redemptions ceiling, from the holders — because no counter is stored anywhere (INV-F4).
Required scope: promotion.read (Read promotions).
path Parameters
idPromotion UUID
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
How much of each ceiling is spent › Responses
OK
Add a ceiling
Appends a limit row and returns the promotion. A limit is a cap on a counted thing over a set of axes inside a window, counted from the ledger and never from a stored counter: "1000 redemptions" is redemptions·promotion·lifetime, "€200 per customer per month" is amount·customer·calendar_month. A row naming a customer is an OVERRIDE: it applies to them alone and replaces the same-shaped row without a customer, and it is the only kind of row that may carry its own negotiated-price policy. On a live promotion this goes through the narrowing rules.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Add a ceiling › Request Body
at_ceilingWhat happens to a firing that does not fit.
countsWhat is counted, read from the firing ledger — or, for redemptions, from the redemption table.
overThe bucket axes: promotion, customer, subscription, price_key. promotion means no extra axis and may not be combined with the others.
windowThe horizon one bucket covers.
The cap for an amount ceiling, as a money table. The entries price named currencies; the default is one currency-less value spent in whatever currency the bill is in, for every currency the entries do not name. An entry always beats the default, at least one of the two is required, and nothing is ever converted. A bill this table answers for in neither way is refused with currency_mismatch. Required for an amount ceiling and refused for any other.
audience_idMakes this an override row for the members of that audience, replacing the same-shaped row naming no audience. One customer is an audience with one explicit include. A customer in two override audiences of one shape gets the FIRST row in authoring order.
ceiling^-?\d+(\.\d+)?$The cap, per bucket, for a redemptions, firings or units ceiling. Greater than zero, and refused for an amount ceiling, whose cap is money and goes in amount.
negotiated_pricesOverrides the promotion's negotiated-price policy for that audience. Only an override row may carry it.
periodsn, required when the window is first_n_periods and refused otherwise.
Add a ceiling › Responses
Created
How much of each ceiling is spent
Deprecated: use GET /promotions/{id}/limit-usage instead. Removed on 2026-11-30.
Every authored ceiling of this promotion with what it has used and what is left, across all holders. used is the LARGEST SINGLE BUCKET of the row and never the sum across buckets: a ceiling applies per bucket, so a per-customer cap of 10 spent by fifty customers has used 10 and not 500, and the sum would read as five times over a limit that was never breached. It is the same figure, from the same function, that refuses a ceiling lowered below what is already spent, so this read and that refusal cannot disagree.
Rows bound to a customer are reported in their own right, each against that customer's bucket; a promotion-wide row some customer overrides carries overridden_for_some_customer. Everything is counted at read time from the firing ledger — or, for a redemptions ceiling, from the holders — because no counter is stored anywhere (INV-F4).
Required scope: promotion.read (Read promotions).
path Parameters
idPromotion UUID
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
How much of each ceiling is spent › Responses
OK
Remove one ceiling
Drops the row at that position and returns the promotion with the remaining rows renumbered. Removing a ceiling WIDENS the promotion, which is legal — a cap the operator set is theirs to lift, and nothing that already fired is affected. 404 when there is no row at that position.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
positionThe limit row's place in the promotion's limit list, 0-based
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Remove one ceiling › Responses
OK
Replace one ceiling
Replaces the row at that position, keeping its place in the list — the order is what makes override resolution deterministic, so a row is edited in place rather than removed and re-appended. On a live promotion the narrowing rules apply, so lowering a ceiling below what its largest bucket already counts is refused. 404 when the promotion has no row at that position.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
positionThe limit row's place in the promotion's limit list, 0-based
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Replace one ceiling › Request Body
at_ceilingWhat happens to a firing that does not fit.
countsWhat is counted, read from the firing ledger — or, for redemptions, from the redemption table.
overThe bucket axes: promotion, customer, subscription, price_key. promotion means no extra axis and may not be combined with the others.
windowThe horizon one bucket covers.
The cap for an amount ceiling, as a money table. The entries price named currencies; the default is one currency-less value spent in whatever currency the bill is in, for every currency the entries do not name. An entry always beats the default, at least one of the two is required, and nothing is ever converted. A bill this table answers for in neither way is refused with currency_mismatch. Required for an amount ceiling and refused for any other.
audience_idMakes this an override row for the members of that audience, replacing the same-shaped row naming no audience. One customer is an audience with one explicit include. A customer in two override audiences of one shape gets the FIRST row in authoring order.
ceiling^-?\d+(\.\d+)?$The cap, per bucket, for a redemptions, firings or units ceiling. Greater than zero, and refused for an amount ceiling, whose cap is money and goes in amount.
negotiated_pricesOverrides the promotion's negotiated-price policy for that audience. Only an override row may carry it.
periodsn, required when the window is first_n_periods and refused otherwise.
Replace one ceiling › Responses
OK
Narrow a live promotion
The only edit a live promotion takes: bring the window end forward, lower a ceiling, change the audience or the explicit lists, change the display text. The DIRECTION is fixed even though the fields are editable — a later window end, or clearing it, is refused with PROMOTION_WOULD_WIDEN, and a ceiling below what its largest bucket already counts is refused with the figure named so the operator can decide to end the promotion instead. Existing holders are then treated per the promotion's on_narrowing: they keep firing to their own end, or they end at the next bill.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Narrow a live promotion › Request Body
audience_idPoint at a different audience.
customer_excludesReplace the explicit exclude list.
customer_includesReplace the explicit include list.
descriptionReplace the ceilings. A ceiling below what is already counted in its largest bucket is refused with the figure named, so the operator can end the promotion instead.
nameon_narrowingWhether existing holders keep firing to their own end or end at the next bill.
public_descriptionwindow_endBring the last day forward. Refused when it is later than the current end, and clearing it is refused outright.
Narrow a live promotion › Responses
OK
Take the promotion live
Validates the whole draft, freezes the rule — shape and slots, scope, distribution and code, window start, negotiated-price policy, exclusions — and arms the window-end timer in the same transaction, so an armed job for a publish that rolled back cannot exist. A backdated window start is allowed and re-bills nothing: the ledger starts here. An auto-enrol or coupon promotion with neither an audience nor an explicit include list is refused, because it has nobody to reach.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: shape, scope, never_with, limits, customer_lists, translations. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. shape is sent without being asked until 2026-11-30; after that date it is sent only when named here. scope is sent without being asked until 2026-11-30; after that date it is sent only when named here. never_with is sent without being asked until 2026-11-30; after that date it is sent only when named here. limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. customer_lists is sent without being asked until 2026-11-30; after that date it is sent only when named here. translations is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Take the promotion live › Responses
OK
Give a customer the promotion
The operator's door onto the ONE door in: quote signing and the enrolment of auto_enroll and auto_apply promotions arrive at this same verb, so an eligibility rule cannot hold on one path and not another. Refuses by name — PROMOTION_NOT_ELIGIBLE, PROMOTION_LIMIT_REACHED, PROMOTION_ALREADY_HELD, PROMOTION_BLOCKED, PROMOTION_WRONG_CODE, PROMOTION_WINDOW_CLOSED — because each has a different next move. A bundle whose set is complete but whose target has not appeared comes back PENDING: it fires nothing, counts against nothing, and expires at the far edge of its grace window.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: saved, promotion, remaining_limits, until. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400.
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Give a customer the promotion › Request Body
customer_idWho comes to hold it.
anchorA subscription to confine every firing to. Omit to let every line of the customer in scope fire.
codeThe coupon code. Optional here: an operator may give a coupon promotion without it. A code that is sent must match, and a promotion that is not a coupon takes none.
Give a customer the promotion › Responses
Created
List who holds the promotion
Every hold on this promotion — its status, its anchor subscription if it has one, and what it has taken off bills so far, positive and one figure per currency. The saving is summed from the firing ledger on every read and is stored nowhere: the counters this model replaced sat beside their own ledgers and had drifted from them. The saving is sent without include=saved until 2026-11-30; after that only with include=saved.
Required scope: promotion.read (Read promotions).
path Parameters
idPromotion UUID
query Parameters
includeExtra data to add to the response: saved, promotion, remaining_limits, until. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. saved is sent without being asked until 2026-11-30; after that date it is sent only when named here.
limitPage size (default 50, max 200)
cursorOpaque continuation token from the previous response's pagination.cursor. Omit for the first page. Cursors are stateless and do not expire, but are only valid for the list and filters that produced them.
sortOrdering: created_at with an optional :asc/:desc suffix (default created_at:desc). Saved-to-date is not sortable: it is a sum over the ledger, not a column.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
List who holds the promotion › Responses
OK
Change a redemption's status
Sets the redemption's status and answers the redemption. left from pending, active or dormant is the customer's own opt-out, which is RE-JOINABLE: the enrolment sweep will not bring them back, but their own redemption will. left from revoked lifts the block, so the customer may redeem again, but they are not re-admitted: an operator who revoked somebody did not thereby decide to give it back. To block a customer, use revoke.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
ridRedemption UUID
query Parameters
includeExtra data to add to the response: saved, promotion, remaining_limits, until. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Change a redemption's status › Request Body
statusleft ends a held redemption as the customer's own opt-out, which is re-joinable, and lifts the block on a revoked one, so the customer may redeem again (they are not re-admitted). To block, use POST /promotions/{id}/redemptions/{rid}/revoke.
Change a redemption's status › Responses
OK
The customer opts out
Deprecated: use PATCH /promotions/{id}/redemptions/{rid} instead. Removed on 2026-11-30.
Ends the hold as left, which is RE-JOINABLE: the enrolment sweep will not bring them back, but their own redemption will. This is not the operator's block — use revoke for that.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
ridRedemption UUID
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
The customer opts out › Responses
No Content
Block the customer, optionally taking the money back
Ends the hold as revoked — redemption is refused until it is unblocked. With clawback, every settled firing of the redemption is reversed and the money documents for the ones that landed on finalised invoices are issued IN THE SAME TRANSACTION: the reversal row is the latch, so a crash cannot leave money recorded as returned and never credited. In a process with no credit-document issuer the clawback refuses with 503 rather than writing reversals nobody will document. Answers the revoked redemption.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
ridRedemption UUID
query Parameters
includeExtra data to add to the response: saved, promotion, remaining_limits, until. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400.
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Block the customer, optionally taking the money back › Request Body
clawbackReverse every settled firing of this redemption and issue the money documents for the ones that landed on finalised invoices. The reversals and the documents commit together.
Block the customer, optionally taking the money back › Responses
OK
Lift the block
Deprecated: use PATCH /promotions/{id}/redemptions/{rid} instead. Removed on 2026-11-30.
Moves a revoked hold to left, so the customer may redeem again — but they are not silently re-admitted. An operator who revoked somebody did not thereby decide to give it back.
Required scope: promotion.write (Write promotions).
path Parameters
idPromotion UUID
ridRedemption UUID
Headers
Idempotency-KeyUnique key that makes this POST safe to retry: repeats with the same key replay the first response instead of re-executing. Replays are scoped to the retrying principal (same API key / user) and kept for 24 hours. Required on every POST.
Client-generated idempotency key (e.g. a UUID).
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
Lift the block › Responses
No Content
The promotions on this subscription
Every promotion the subscription's customer still holds that may take money off THIS subscription's lines: the ones tied to it, and the ones tied to no subscription, which apply to all of the customer's. Each carries what it has saved on this subscription, what is left of its ceilings for the customer, and until, its last day as far as it is known today: the promotion's closing date, or the end of the first periods it covers counted on this subscription's own clock, whichever comes first. Each row is the redemption; include= adds saved, promotion, remaining_limits and until, and all four are sent without include= until 2026-11-30, after that only the ones named.
Required scope: promotion.read (Read promotions).
path Parameters
idSubscription UUID
query Parameters
includeExtra data to add to the response: saved, promotion, remaining_limits, until. Send a comma-separated list or repeat the parameter. An unknown name is refused with 400. saved is sent without being asked until 2026-11-30; after that date it is sent only when named here. promotion is sent without being asked until 2026-11-30; after that date it is sent only when named here. remaining_limits is sent without being asked until 2026-11-30; after that date it is sent only when named here. until is sent without being asked until 2026-11-30; after that date it is sent only when named here.
Headers
X-Workspace-IdSelects the workspace this request operates in, by workspace UUID or slug (e.g. a sandbox workspace for test integrations). Omitted: the organization's default live workspace. Unknown workspace: 404; workspace outside your organization: 403. Discover workspaces via GET /workspaces.
Workspace UUID or slug.
The promotions on this subscription › Responses
OK