Run promotions and voucher codes
A promotion on this platform is a rule that reduces a cart that is already priced, and a voucher is a code that unlocks one. This page builds both over the API: an automatic promotion with a condition and an effect, the stacking rules that decide what happens when several match, a batch of single-use voucher codes, and the four calls a checkout makes (evaluate, reserve, commit, release) so that a code is counted against the order that used it, and credited back when you tell the platform the order was cancelled or returned.
This is for developers and integrators wiring promotions into a storefront, a checkout or an ERP sync. You need a tenant with the Promotions core app and an API key with the promotions.* scope (see which scope covers which app). New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant. Your cart must come from a pricing step you already run, because promotions never set a price. Every call goes through the API gateway with the same two headers. Make the write calls and the checkout calls from your backend and keep the key out of the browser. The examples use curl and jq, and a value in angle brackets, such as <CART_ID>, is a UUID you supply (the cart, order, contact and organization ids are all UUIDs):
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
What the Promotions App does not do
- It reduces prices. It does not set them. A cart line with no
unit_priceis refused with a400, never guessed. Resolve the price first, with B2B pricing, then ask Promotions what the cart is owed. - A fresh tenant has no promotions. Installing the App creates none. There is nothing to seed and nothing to delete.
evaluatechanges nothing. Onlycommitspends a promotion. Callevaluateas often as you like, even on every keystroke in a code field.- A voucher needs a promotion behind it. A code cannot exist on its own, and it cannot be created for an automatic promotion (a
409, "this promotion applies without a code").
1. Create a promotion
A promotion is a row with a reach: automatic applies to any cart that meets its conditions, code applies only when the buyer enters one of its vouchers. It starts as a draft, which never applies. Create it, then set it active when its conditions and effect are in place.
PROMO_ID=$(curl -s -X POST https://api.revenexx.com/v1/promotions/promotions \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Ten percent from 200",
"code": "TEN-FROM-200",
"reach": "automatic",
"currency": "EUR",
"priority": 10,
"budget_redemptions": 5000,
"limit_per_contact": 1
}' | jq -r .id)
The fields you will reach for first:
| Field | What it does |
|---|---|
status | draft (default), active, paused or archived. Only active applies. |
priority | Higher goes first. It decides who the stacking limit drops, which entry is "first" in a first_match group, and the order promotions are applied in, so stacked effects compound in that order. |
currency | A cart in another currency skips the promotion. Empty applies in every currency. |
starts_at, ends_at | The window it runs in. Empty ends never. |
budget_discount, budget_redemptions | The most it may give away in total, and the most times it may be redeemed. Reaching one stops the promotion, it does not refuse the cart. A budget is checked at evaluate, against what has been committed so far, so the last cart can go past it and so can checkouts that run at the same time (as of 2026-09). |
limit_per_contact, limit_per_organization | Per-buyer caps, counted from committed redemptions plus live holds. On a promotion with thousands of redemptions treat the cap as a guard, not a hard guarantee (as of 2026-09). |
exclusive | When true, applying it ends the whole evaluation: nothing else applies. |
A recurring window (recurrence_kind of weekdays or days_of_month, with recurrence_from and recurrence_until as times of day) is built from recurrence_weekdays (1 for Monday to 7 for Sunday) or recurrence_days (1 to 31, or "last") and is read in the promotion's own timezone, else the tenant's default (Europe/Berlin unless the tenant changed it). You cannot mix two recurrence shapes on one promotion. GET /v1/promotions/promotions/{id}/state tells you what a promotion is doing right now: effective_state is draft, scheduled, running, paused, expired or archived.
2. Add a condition
A promotion with no conditions matches every cart. A condition is a question (one subject compared with one value) or a group that holds other rows and requires all or any of them. Rows with no parent_id are the outermost level, joined by the promotion's condition_match.
curl -s -X POST https://api.revenexx.com/v1/promotions/conditions \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<EOF
{ "promotion_id": "$PROMO_ID", "kind": "question", "subject": "cart_value", "comparison": "gte", "compare_value": 200 }
EOF
The comparisons are eq, neq, gt, gte, lt, lte, between (with compare_value_to), in, not_in, is_true and is_false. negate: true turns any row around, which is how you carve a range out of an offer. right_subject with a factor and an addend compares one subject with another instead of a literal.
The subjects are a closed list. GET /v1/promotions/vocabularies returns every one, so read it rather than copying a list from a page. They fall in three families:
- The cart:
cart_value,item_count,line_quantity,product_sku,product_id,product_category,product_attribute(matched asname:value),market,channel. They read what you send:category,product_idandattributeson each line, andmarketandchannelon the request (or theX-Revenexx-Marketheader). A condition on a field the cart does not carry is never an error:marketandchannelcome back as unanswerable, and a missing category or SKU simply does not match. - The buyer's identity:
contact_role,organization_segment,first_order. - The buyer's history:
revenue_total,revenue_365d,revenue_90d,order_count,order_count_365d,avg_order_value.
organization_id for a history subject or first_order to be answerable; when they are missing or older than the tenant's freshness limit (48 hours by default), or, for the money subjects, in two currencies, the condition fails and the promotion is reported as skipped. It does not apply "just in case".Conditions nest to a depth set by the tenant (three levels by default, at most six). A question nested too deep is a 400 on write. A group nested too deep is not refused until you run the check below. An empty group is accepted on write and holds for every cart, which is rarely what anyone meant. Before you activate a promotion, ask POST /v1/promotions/promotions/{id}/conditions/check. It finds empty groups and trees that are too deep before a shopper does.
3. Add an effect
An effect says what the promotion does. kind is one of discount, free_item, surcharge, notice, bundle or custom.
curl -s -X POST https://api.revenexx.com/v1/promotions/effects \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<EOF
{ "promotion_id": "$PROMO_ID", "kind": "discount", "value_type": "percentage", "amount": 10, "target_scope": "cart_subtotal", "max_discount": 50 }
EOF
Then activate it:
curl -s -X PUT "https://api.revenexx.com/v1/promotions/promotions/$PROMO_ID" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "active" }'
What each kind needs, and what a write refuses with a 400:
discountneedsvalue_type(percentage,amountorfixed_price),amountand atarget_scope(unit_price,line_total,cart_subtotal,grand_total,shippingorpayment_fee).max_discountcaps it and is not allowed withfixed_price.applies_tois{ "skus": [], "product_ids": [], "categories": [], "attribute": { "name": "", "value": "" } }, and it narrows the base only for theunit_priceandline_totalscopes. Oncart_subtotalorgrand_totalthe percentage is taken on the whole cart and then spread over the matching lines, so "10 percent off category X" must useline_total.attributeis an object, and a plain string matches nothing.free_itemneedsfree_items, a list of at least one SKU string. Arequires_choiceeffect with a single SKU still needs that SKU inchosen_itemswhen you reserve, even thoughevaluatereports no choice. Withrequires_choicethe buyer picks, andreservethen needs their pick (step 6.2).bundlealso needsvalue_typeandamount, and counts units, not money: "buy three, pay two". It names a bundle, and aunit_choiceofcheapest,dearest,positionorall. The bundle row (POST /v1/promotions/bundles) holdsunits_required(2 by default),max_per_cartand how units are allocated.noticecarries amessageand discounts nothing. Use it for "add 20 more for free shipping".surchargeneedsvalue_typeandamount, adds to the cart subtotal, and is refused unless the tenant has switched surcharge effects on. They are off by default.customhands a payload of your own shape back to you. Step 7.
Effects are measured on what is left after the ones before them, in position order within a promotion and in priority order across promotions, so two ten-percent discounts are not twenty.
4. Decide what happens when several promotions match
When more than one promotion matches a cart, the platform decides in this order:
exclusive. A matching exclusive promotion ends the evaluation. Every other promotion is skipped with the reasonexclusivity. It beats groups.- Groups. A promotion in a stacking group follows the group's
mode. A promotion in no group follows the tenant'sdefault_stacking_mode. The modes arestack(all add up),highest_value(only the one worth most) andfirst_match(only the first). Groups nest, and a nested group competes in its parent as one entry, worth what its members give after the group's own mode. - The ceiling.
max_stacked_promotions(5 by default, at most 50) is applied last, bypriority. The losers are skipped withstacking_limit.
curl -s -X POST https://api.revenexx.com/v1/promotions/groups \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "seasonal", "name": "Seasonal offers", "mode": "highest_value" }'
Put a promotion in it with group_id on the promotion. To let the platform try different orders of application and keep the best one for the buyer, set search_best_combination on the promotions that should take part. The search is bounded (at most six opted-in promotions by default, at least two), and the answer says when it did not run.
max_codes_per_cart limits how many voucher codes one cart may carry. It is 1 by default and can be raised to 20. The settings behind all of this (default_stacking_mode per market, the ceilings per tenant) are set in Cockpit.
5. Make a batch of voucher codes
A voucher is one code. A batch is a named supply of them, made from a pattern, and accounted for as one. First create the batch for a code promotion, then generate into it:
CODE_PROMO_ID=$(curl -s -X POST https://api.revenexx.com/v1/promotions/promotions \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Spring mailing", "code": "SPRING", "reach": "code", "currency": "EUR" }' | jq -r .id)
BATCH_ID=$(curl -s -X POST https://api.revenexx.com/v1/promotions/batches \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "promotion_id": "'"$CODE_PROMO_ID"'", "name": "Spring mailing 2026" }' | jq -r .id)
Give the code promotion an effect as in step 3 (with $CODE_PROMO_ID) and activate it. Then generate:
curl -s -X POST "https://api.revenexx.com/v1/promotions/batches/$BATCH_ID/generate" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "count": 1000, "pattern": "SPRING-####-####", "usage_limit": 1, "request_ref": "spring-mailing-2026" }'
The answer is { "batch_id": …, "created": 1000, "repeated": false, "total": 1000 }.
patternwrites the code. Each#becomes a random character from the alphabet, and every other character is kept, so a fixed prefix isSPRING-####. The default pattern is eight#.alphabetis the set of characters a#draws from. The default leaves out the ones people confuse when they read a code off paper (noI,L,O,0or1).usage_limitis how often one code can be redeemed. It is 1 by default, and 0 means unlimited.request_reflets a retry of a finished generation answercreated: 0, repeated: trueinstead of making the codes twice. A reference is unique per tenant, so never reuse one for a second batch. A generation that fails part-way is not a finished one: check the batch withexportbefore you retry, because a retry makes a fullcountagain.- A batch makes at most 50,000 codes at a time by default. The tenant can set it anywhere from 1 to 500,000. A larger
countis a400("a generation makes at most N codes at a time"), as is a pattern that cannot yield that many distinct codes.
Three more calls cover the other ways codes come and go:
POST /v1/promotions/batches/{id}/generate-forwithrecipients(a list of contact ids) issues one single-use code per contact, and answers with thecontact_idandcodeof each. It has norequest_ref: a retry after a timeout gives every contact a second code, soexportfirst.POST /v1/promotions/batches/{id}/importwithcodesbrings in codes made elsewhere. A code that already exists is left alone and listed underrejected. Imported codes default tousage_limit: 1, so sendusage_limit: 0for codes that can be used without limit. An import cannot set aresidual_value.GET /v1/promotions/batches/{id}/exportlists every code with its status and how often it was used, for the mailing.
To make one code by hand, POST /v1/promotions/vouchers with a promotion_id and a code. A voucher can carry starts_at and ends_at, a contact_id (only that buyer may redeem it), a usage_limit (a hand-made code defaults to 0, unlimited, unlike a generated one) and a residual_value for a gift-card-style balance that each redemption draws down. A duplicate code is a 409, even when it differs only in case.
How a code is read. The platform trims leading and trailing spaces and turns the code to upper case before it looks it up, so spring-ab42-cd34 and SPRING-AB42-CD34 are one code. Spaces inside a code are kept. A tenant can make codes case-sensitive, and then the case must match.
A redeemed code cannot be deleted. DELETE answers 409. Set its status to disabled instead. The same holds for a promotion that has been redeemed: archive it.
6. Run the checkout
Four calls, in this order. Nothing is spent until the third. The second only counts toward the buyer's own limits.
6.1 Evaluate the cart
Send the priced cart and any codes the buyer entered. The answer says what each promotion takes off and why the rest did not apply.
curl -s -X POST https://api.revenexx.com/v1/promotions/evaluate \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"currency": "EUR",
"contact_id": "<CONTACT_ID>",
"organization_id": "<ORGANIZATION_ID>",
"codes": ["SPRING-AB42-CD34"],
"lines": [
{ "position": 1, "sku": "CHAIR-01", "quantity": 4, "unit_price": 59.00, "line_total": 236.00 }
],
"subtotal": 236.00
}'
Read these parts of the answer:
applied: one entry per promotion that took something off, withpromotion_id,voucher_idandvoucher_codeif a code unlocked it, and theamount. You pass these toreservenext. A voucher with aresidual_valueis capped at what is left on it, but only in thisamount: the per-linelines,totalsandeffects.reductionsare not reduced. Charge the sum ofapplied[].amount, nottotals.discount_total, when a balance-carrying voucher is in play.linesandtotals: the discount attributed to each line and to each scope, withdiscount_total.effects: the free items, notices and custom payloads that came with it.rejected_codes: a code that did not work is listed here with a reason (unknown,expired,not_yet_valid,disabled,used_up,not_for_this_buyer,conditions_not_met,codes_per_cart_limitand others), and it is never an HTTP error. Show the buyer the reason.skipped: promotions that matched and lost, each with a reason (exclusivity,lower_value,not_first,stacking_limit,promotion_exhausted,per_buyer_limit_reachedand others). The tenant can switch this list off;disclosedsays whether it did. A promotion that a code unlocked is reported inrejected_codesinstead, whatever the reason, so read both lists.
A cart carries at most 500 lines. A line without unit_price, a missing or malformed currency or a bad lines list is a 400. Two read-only helpers sit beside it. POST /v1/promotions/codes/check with { "code": "…" } tells you whether a code exists and is in date and in use limits, without revealing whose it is. It ignores the cart: conditions, per-buyer limits, budgets and currency are only checked by evaluate, so do not tell a shopper "valid" from this call alone. Send the contact_id, or a code bound to one buyer reads not_for_this_buyer. POST /v1/promotions/available with a cart lists promotions the buyer could still reach and how far away they are (distance), for a banner. It never returns voucher codes.
6.2 Reserve a hold
When the buyer moves to pay, hold what evaluate applied, so the promotions count toward the buyer's own limits while they pay. Reserve replaces the cart's whole hold set, so send everything you want held, every time.
curl -s -X POST https://api.revenexx.com/v1/promotions/reserve \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"cart_id": "<CART_ID>",
"contact_id": "<CONTACT_ID>",
"organization_id": "<ORGANIZATION_ID>",
"currency": "EUR",
"promotions": [
{ "promotion_id": "<PROMOTION_ID>", "voucher_id": "<VOUCHER_ID>", "amount": 23.60 }
]
}'
The answer is { "cart_id", "held", "released", "expires_at", "redemptions" }. The hold lasts 60 minutes by default (hold_lifetime_minutes; the tenant can set 5 minutes to 7 days). Reserving the same cart again is safe. A hold counts toward limit_per_contact and limit_per_organization, and nothing else. Send the contact_id and organization_id the limits are counted by, or the cap never sees the redemption: it does not reserve a voucher, a budget or a redemption count. from_cart_id moves a hold when a guest's cart is merged into an account's.
reserve does not re-check budgets, limits or whether a voucher is valid. It records what you pass it, and only evaluate checks. Take promotion_id, voucher_id and amount from the applied entries you just evaluated, not from figures you computed yourself. An expired hold stops counting at once.A hold does not lock a voucher, so two carts can both evaluate and commit the same single-use code (as of 2026-09). If that matters, serialize checkouts per code on your side, or check each applied entry's voucher_id against your own order records before you commit.A buyer's own hold counts against their per-buyer limit too. If you evaluate a cart again after you reserved it (the buyer came back from the payment page, say) and the promotion has a limit_per_contact, evaluate can skip it as per_buyer_limit_reached, and reserving that answer would release the hold. Keep the applied set you already reserved, or release the cart first, then evaluate, then reserve.A free_item effect with requires_choice needs the buyer's pick in chosen_items on that promotion's entry in promotions[], one of the items the effect offered, or reserve answers 400.
6.3 Commit on the order
When the order is placed, commit the hold under the order's id. That is what spends the budget, the per-buyer limit and the voucher.
curl -s -X POST https://api.revenexx.com/v1/promotions/commit \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "order_id": "<ORDER_ID>", "cart_id": "<CART_ID>", "contact_id": "<CONTACT_ID>", "organization_id": "<ORGANIZATION_ID>", "currency": "EUR" }'
Commit is idempotent on order_id. A repeat answers repeated: true and moves nothing, so retry it freely on a timeout. If the hold has expired, the answer is 409 ("the hold on this cart expired — evaluate the cart again before committing"). Once the expired hold has been cleared, the answer is 200 with committed: 0 instead. Always compare committed with the number of promotions you reserved, treat a shortfall like the 409, and evaluate, reserve and commit again.
Pass terms if you will ever take back part of an order (step 6.5). It is an object keyed by promotion id. effects.reductions in the evaluate answer is one flat list, each entry naming its promotion_id. Group it by promotion_id, and put each group's { "line_position", "amount" } entries into terms["<PROMOTION_ID>"].reductions. The platform keeps it with the redemption.
6.4 Release
Releasing gives a hold or a committed redemption back. Pass a cart_id to drop a hold (the buyer left), or an order_id to reverse what an order spent (it was cancelled).
curl -s -X POST https://api.revenexx.com/v1/promotions/release \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "order_id": "<ORDER_ID>", "reason": "order_cancelled" }'
Release by cart_id only before you commit. After a commit, release by order_id: a release by cart_id on a cart whose order is committed still moves the counters back, so a single-use code becomes usable again and the budget is under-counted (as of 2026-09). Releasing an order reverses the committed redemptions. A repeat takes nothing back twice and publishes no second event: it answers released: 0.
6.5 Take back part of an order
POST /v1/promotions/returns credits back a return. With all: true, or no lines, it reverses every redemption on the order. Otherwise name the returned lines by position. Returns are whole-line: a quantity on a line is not read (as of 2026-09).
What comes back depends on the promotion's return_behaviour:
reverse_proportionallygives back the discount carried by the returned lines. That needs theterms.reductionsyou passed at commit. Without them a partial return credits nothing. To reverse the whole order, sendall: true: naming every line bypositionwithouttermscredits nothing either.reevaluatere-prices what is left: you sendremaining_amounts(promotion id to the discount that still applies) and the platform credits the difference.
Send a return_ref. A repeat with the same reference credits nothing more. The budget and any voucher balance are credited back, and a redemption reduced to zero becomes released.
7. Return your own payload with a custom effect
For something that is not a discount, such as a gift wrap, a sample or a loyalty multiplier, register a custom effect type (a named shape for a payload) with POST /v1/promotions/custom-effect-types, for example { "name": "loyalty-points", "payload_shape": { "required": ["points"], "properties": { "points": { "type": "integer" } } } }. Then create an effect with kind: "custom", the type's custom_type_id and a custom_payload. When the promotion applies, evaluate hands the payload back under effects.custom, untouched, with the type's name and shape_version. It is yours to act on.
- Only
requiredkeys are checked. A missing one is a400. Property types are not validated (as of 2026-09), so validate on your side. A payload is at most 8,192 characters as JSON. - A shape may grow, not shrink, while in use. Removing a key, or adding a new
requiredone, while an effect uses the type is a409that lists the promotions. Register a second type and move them. A type in use cannot be deleted. - Set
carries_amountwhen the effect is also a discount. It then needs avalue_typeand atarget_scopelike any other.
8. Listen for what happened
The App publishes six events. Subscribe from an App manifest or a Webhook destination, using the exact topic strings your tenant's catalog lists (see Event types). Three describe redemptions, and you will usually want those:
| Event | Fires when | Carries |
|---|---|---|
redemption.committed | A commit spends a promotion. | redemption_id, promotion_id, promotion_code, voucher_code, order_id, contact_id, amount, currency, committed_at |
redemption.released | A release or a return gives one back. | redemption_id, promotion_id, promotion_code, order_id, amount, currency, reason, released_at. After a partial return, amount is what the redemption still holds, not what was credited: do not drive an ERP credit from it. |
promotion.exhausted | A commit brings a budget to its limit. Once per promotion. | promotion_id, limit_kind (budget_discount or budget_redemptions), budget, consumed |
An event that fails to publish does not fail the call: the redemption ids come back in events_pending, so you know what to reconcile. Repeating a commit publishes them again. Repeating a release does not. Repeating a returns can publish redemption.released again for every live redemption on the order, including ones the return never touched, so de-duplicate on the event id and do not treat the event alone as proof a promotion was returned.
redemption.released (as of 2026-09). A hold that lapses stops counting, but nothing tells you so. If you track holds downstream, expire them on your side at expires_at.The other three, promotion.created, promotion.updated and voucher.created, are noisy: promotion.updated also fires when a commit, release or return changes the promotion's running totals, and one generate of 50,000 codes sends 50,000 voucher.created events. Subscribe only if you need them.
9. Restore a promotion and empty the trash
Deleting a promotion is two steps. The first DELETE /v1/promotions/promotions/{id} moves it to the trash: it applies nowhere, its code stays reserved, and its conditions, effects, bundles, vouchers and batches stay with it untouched. A promotion that has been redeemed is refused with 409 and archive_instead; archive it. A second DELETE on a promotion already in the trash deletes it for good, and only then do its parts go and promotion.deleted fire. If something still blocks the final delete, it answers 409 with delete_blocked.
The first delete fires promotion.trashed and a restore promotion.restored. promotion.deleted fires only once the record is deleted for good. See Deletes and the trash.
Three more calls manage what is in the trash.
POST /v1/promotions/promotions/{id}/restoretakes a promotion out of the trash. It clears the trash marks and nothing else: its conditions, effects, bundles, vouchers and batches were never touched, so they come back with it, and it applies again exactly as it did before. It needs the same right as deleting. A promotion that is not in the trash answers409withnot_in_trash, and an id no promotion has answers404.POST /v1/promotions/promotions/trash/emptydeletes every promotion in the trash for good. A promotion that something still blocks stays in the trash, and the200answer names it with its reason:deletedlists the ids that went,blockedlists the ones that stayed. It needs its own right, apart from deleting.POST /v1/promotions/promotions/trash/purgedeletes the promotions that have been in the trash longer than the tenant's trash retention, which is 90 days unless the tenant set another. It follows the same rules as emptying the trash, and a blocked promotion stays with its reason. If the retention cannot be read, nothing is deleted and the answer isskipped. The platform runs this purge every day on a schedule; the call runs it by hand.
Emptying the trash and the purge answer 403 to a storefront contact.
Reading and filtering the trash. GET /v1/promotions/promotions takes trashed: exclude (the default) leaves trashed promotions out, only returns the trash alone and include returns the live and the trashed promotions together. Each promotion carries the fields below. The list call takes trashed as its only trash filter; the other fields are not filters there.
| Field | What it holds |
|---|---|
deleted_at | When the promotion went to the trash; null while it is not in it. A trashed promotion applies nowhere and keeps its code reserved. |
deleted_by | Who moved it there: an e-mail, or system. A name for the trash view, not an identity to check. |
deleted_by_id | The platform subject of that person, kept apart from the name so the history can be pseudonymised. |
purge_at | When the daily purge deletes it for good: the moment it was trashed plus the tenant's trash retention (90 days unless the tenant set another). |
trash_batch | One id per trash action. Restoring brings back exactly that batch. |
purge_blocked_reason | Why the purge could not delete it for good; empty unless a purge tried and was refused. Cleared on restore. |
The platform sets these fields when a promotion is deleted and clears them when it is restored. They are information to read, so do not send them in a write.
Troubleshooting
- The promotion never applies. Read
GET /v1/promotions/promotions/{id}/state.effective_stateis notrunningwhen it is still adraft, paused or archived, andlive_nowsays whether the promotion is inside its dates and recurrence right now, whicheffective_statedoes not. Then checkskippedandrejected_codesin theevaluateanswer, which name the reason. skippedis empty though a promotion should have matched. The tenant may have switched disclosure off (disclosed: false). Test on a tenant where it is on.- A history condition (
revenue_365d,order_count) never holds. The buyer's figures are missing, older than the freshness limit or in two currencies. The skip reason iscondition_unanswerable,metric_too_oldormetric_mixed_currency. Send anorganization_id, and check the figures on the organization. - A promotion is skipped for
identity_required. The cart carried neither acontact_idnor anorganization_id, and the market does not allow promotions for guests. - A code is in
rejected_codes. Readreason.not_for_this_buyerandreservation_requiredare about the voucher,conditions_not_metabout the promotion,codes_per_cart_limitabout how many codes one cart may carry. 400"carries no resolved unit price". The line has nounit_price. Promotions never price a line. Resolve it first.409on commit, "the hold on this cart expired". Evaluate, reserve and commit again. A checkout that takes longer than the hold lifetime needs to reserve again. Later, the same situation answers200withcommitted: 0, so check the count.409on creating a voucher. The code exists already. Codes are compared after trimming and upper-casing.400on generate. Thecountis over the batch limit, or the pattern cannot yield that many distinct codes. Lengthen the pattern.401or403. The tenant slug or key is wrong, or the key has no access to Promotions. See API keys and Errors.
Where to go next
- Set up B2B pricing — the price a cart arrives with before a promotion touches it.
- Ship, cancel and return an order — cancelling or returning an order does not release its promotion by itself: you call
releaseorreturns, and that guide shows where in the sequence. - Turn an accepted quote into an order — the other way an order gets placed.
- Orders — the order you commit a promotion on.
- Customers — the buyer figures that history conditions read.
- Event types — how to find the topic string of a redemption event.
- Webhooks — deliver a redemption to your own service.