Promotions are Flint's discount rule engine for automatic discounts, code campaigns, buy-X-get-Y offers, eligibility conditions, and stacking control. A promotion is the campaign-level rule. Promotion codes are nested child resources under it, so one promotion can have many independently expiring codes or no codes at all.
Use Coupons when you need the simple case: one reusable code, one percent or amount discount, and a flat product, variant, bundle, or category restriction list. Coupons are a facade over this same engine. Use Promotions when you need automatic application, multiple codes, buy-X-get-Y, customer or metadata conditions, service-charge discounts, or precise stacking behavior.
Because coupons and promotions share one engine and one ID space, the coupons endpoints only surface promotions that were created through the coupons API. A promotion created through /v1/promotions (and any buy_x_get_y promotion) is not readable at GET /v1/coupons/{id}; read it at GET /v1/promotions/{id}.
Core shape#
application_method decides what the promotion does. eligibility_rules decides when it can apply. discount_class decides whether it discounts the order, line items, or service charges. combines_with, exclusivity, and stacking_mode decide how it interacts with other promotion discounts on the same order.
Only name and application_method are required. discount_class defaults to order, redemption_type defaults to automatic (or code when you include codes), display_name defaults to name, stacking_mode defaults to continue, and allocation defaults by class. name is your internal campaign label; display_name is the buyer-facing string shown at checkout. Set display_name only when your name is operational rather than buyer-safe.
All timestamps (created_at, updated_at, expires_at, schedule.starts_at, schedule.ends_at) are RFC3339 UTC, for example 2026-04-01T00:00:00Z, on both read and write.
Rule groups#
Rule groups use an array shorthand for a simple list of rules (an implicit AND), or { "all": [...] } / { "any": [...] } for nested logic. A group sets exactly one of the array shorthand, all, or any. Rule values are public JSON primitives or money objects, and values is always an array even for a single value:
[
{ "attribute": "line_item.category", "operator": "in", "values": ["hats", "shirts"] },
{ "attribute": "customer.is_verified", "operator": "eq", "values": [true] },
{ "attribute": "order.subtotal", "operator": "gte", "values": [{ "amount": 5000, "currency": "USD" }] }
]
Rule attributes#
Every rule targets one attribute. The attribute determines the value type and which operators are allowed; sending an unknown attribute, a disallowed operator, or a value of the wrong type is rejected at create time (you will not ship a promotion that silently never applies). metadata.<key> reads a string from the customer's metadata (for example metadata.channel). Orders without a customer, or customers without the key, fail closed unless you use is_defined.
| Attribute | Value type | Operators | Notes |
|---|---|---|---|
order.subtotal | money | gt, gte, lt, lte | Order subtotal in the order currency. |
matched_items.subtotal | money | gt, gte, lt, lte | Subtotal of only the line items matched by the same rule group. |
order.currency | string | eq, ne, in, contains, is_defined | ISO currency code, for example USD. |
customer.group_id | string | eq, in, is_defined | Customer group identifier. |
customer.is_verified | boolean | eq | Whether the customer is verified. |
line_item.product_id | string | eq, ne, in, contains, is_defined | Catalog product ID (prod_...). |
line_item.variant_id | string | eq, ne, in, contains, is_defined | Catalog variant ID (var_...). |
line_item.bundle_id | string | eq, ne, in, contains, is_defined | Catalog bundle ID. |
line_item.category | string | eq, ne, in, contains, is_defined | Matches any of the line item's catalog categories. |
line_item.quantity | number | eq, ne, gt, gte, lt, lte, in, is_defined | Unsettled quantity of the line item. |
charge.type | string | eq, ne, in, contains, is_defined | Service charge type: service_fee, delivery_fee, shipping_fee, handling_fee, and similar. |
delivery_choice.type | string | eq, ne, in, contains, is_defined | Selected delivery type: shipment, pickup, or local_delivery. |
delivery_choice.method_id | string | eq, ne, in, contains, is_defined | Stable delivery method ID selected for a choice group. |
delivery_choice.raw_amount | money | gt, gte, lt, lte | Delivery amount before delivery discounts and tax. |
delivery_choice_group.id | string | eq, ne, in, contains, is_defined | Choice-group ID that owns the selected method and any attached service charge. |
metadata.<key> | string | eq, ne, in, contains, is_defined | Reads a string value from the customer's metadata. |
Additional operator rules:
- Only
inaccepts more than one value. Every other operator with more than one value is rejected. - When an attribute allows
is_defined, it takes no values (and nocurrency_options); it tests only for presence. containsis a case-insensitive substring match on a single string value, not list membership. For "attribute is one of these values," usein.- Money values require a non-empty
currency, and money-valued rules may carry acurrency_optionsmap to set per-currency thresholds.
The same attribute/type/operator table is published in the OpenAPI spec as the x-flint-rule-attributes extension on the PromotionRule schema, so you can generate or validate rules against it directly. If you send an operator an attribute does not allow, the request is rejected at create time with INVALID_OPERATOR, and the error message lists the operators that are valid for that attribute.
Automatic promotion#
Automatic promotions apply during order pricing when they are active, eligible, and allowed by the merchant or checkout promotion settings.
{
"name": "Hat launch",
"display_name": "20% off hats",
"redemption_type": "automatic",
"discount_class": "line_item",
"application_method": {
"type": "percent_off",
"percent_off": 20,
"discounted_item_rules": [
{ "attribute": "line_item.category", "operator": "eq", "values": ["hats"] }
]
}
}
Eligibility vs. targeting vs. class#
Three fields decide who qualifies, what gets discounted, and which layer the discount sits on. They are distinct on purpose:
eligibility_rulesis the gate: conditions the order, customer, or items must satisfy before the promotion applies at all. Omit it and the promotion always qualifies.- The effect's
discounted_item_rules(onpercent_offandamount_off) is the target: which line items receive the discount. discount_classis the layer:orderdiscounts the whole order,line_itemdiscounts matched items,service_chargediscounts fees. It also changes the math (a whole-order percent vs. a per-item percent).
For a line_item promotion, discounted_item_rules is required unless your eligibility_rules already consists only of line-item predicates (line_item.* or matched_items.subtotal), in which case those matched items are the discount target. If your eligibility_rules gate on order.subtotal (an order-level condition), you must also send discounted_item_rules to say which items to discount, or you will get DISCOUNTED_ITEM_RULES_REQUIRED.
Allocation#
allocation controls how the effect spreads across the matched items when more than one qualifies. It defaults to each for line_item and service_charge promotions and across for order promotions.
eachapplies the effect to every matched item independently. A fixedamount_offis charged per item (10 USD off 3 matched items discounts 30 USD, each capped at that item's price); apercent_offis taken from each item.acrossapplies the effect once to the matched set as a whole. A fixedamount_offis the whole amount one time (10 USD off the group, not prorated per item); apercent_offis taken from the combined subtotal.
The distinction matters most for fixed amount_off, where each and across differ by the number of matched items. For percent_off both produce the same total and differ only in how the discount is attributed to individual line items. max_discounted_quantity requires each.
Code campaign#
Use redemption_type: "code" when the buyer or server must provide a promotion code. Create and manage codes through /v1/promotions/{promotion_id}/codes; codes are not a scalar field on the promotion because a campaign can issue, disable, expire, and cap many codes independently.
{
"name": "VIP campaign",
"display_name": "VIP savings",
"redemption_type": "code",
"discount_class": "order",
"application_method": {
"type": "amount_off",
"amount_off_money": { "amount": 1000, "currency": "USD" }
},
"codes": [{ "code": "VIP10", "max_uses": 500 }]
}
Apply a discount to an order#
Both coupons and promotions apply through the same order endpoint, POST /v1/orders/{order_id}/discounts, using a discount body with exactly one of coupon, promotion, or manual:
{ "promotion": { "promotion_code": "VIP10" } }
Use coupon (by coupon_id or coupon_code) for the simple flattened case, and promotion (by promotion_id or promotion_code) for the rule-engine case. Use promotion_id for server-side apply flows where you already know the promotion. Use promotion_code when redeeming a customer-entered campaign code. Line-item targeting is rule-driven, not caller-driven: order_line_item_ids is not accepted for coupon or promotion discounts (the rules decide the targets); only a manual discount carries its own manual.order_line_item_ids.
See the Promotions guide for the full apply, preview, reprice, and remove walkthrough, including how a valid-but-unapplied code is reported.
Decline reasons#
When a promotion does not apply, Flint always tells you why with a stable, machine-readable reason. Where the reason rides depends on the surface (the value set is the same everywhere):
- On an apply error (4xx):
error.reason. - On an apply success where a code was accepted but did not win:
meta.warnings[].reason. - On a preview result:
promotion_decline_reasonon each candidate.
The reason is one of:
| Reason | Meaning |
|---|---|
not_eligible | Eligibility rules were not satisfied (also the fallback for any reason a client does not recognize). |
minimum_not_met | A spend minimum was not reached. Apply errors carry required_money, current_money, and gap_money; preview available[] candidates carry threshold_money, current_money, and gap_money. |
expired | The code or promotion ended (schedule.ends_at in the past, or the code expired). |
not_yet_started | The promotion's schedule.starts_at is in the future. |
exhausted | The code's or promotion's max_uses is reached. |
code_required | The promotion is code-gated and no matching code was entered. |
code_invalid | No code record matches the entered string (a typo or unknown code). |
disabled | The promotion's status is disabled. |
automatic_disabled | Merchant settings have automatic promotions turned off. |
codes_disabled | Merchant settings have code promotions turned off. |
already_applied | The promotion is already applied to the order. |
not_combinable | A combines_with class conflict with an existing discount. |
superseded_by_better_offer | A higher-value discount in the same exclusivity group applied instead. |
superseded | An admitted stop_after discount earlier in the order halted this one. |
max_promotions_reached | The order hit max_promotions_per_order. |
no_discountable_balance | Nothing left to discount. |
buy_item_missing | Buy-X-get-Y qualifying items are not present. |
currency_mismatch | The discount or threshold currency does not match the order currency. |
unknown_type | The promotion uses a newer grammar than this integration understands. |
Branch your buyer-facing copy on the reason value, not the human message. A new backend reason a client does not yet know surfaces as not_eligible, so always keep a generic fallback.
Status#
Returned status is computed. It is one of active, disabled, expired, not_yet_started, exhausted, no_active_codes, or deleted. no_active_codes means a code-gated promotion has no usable codes; exhausted means max_uses is reached.
PATCH /v1/promotions/{id} accepts only active or disabled for status, which updates the stored base state. The other returned values are derived from the schedule, usage, and codes; you cannot set them directly. DELETE /v1/promotions/{id} is a soft delete: the promotion moves to status: deleted (a subsequent GET returns it with that status rather than a 404), drops out of default list results, and its codes stop working.
Stacking and conflicts#
Manual discounts are admitted first and are not removed by promotion conflict resolution. Promotion candidates are then evaluated together, including already pending promotion discounts and newly eligible automatic promotions.
combines_with is bidirectional: two promotion discounts can coexist only when each promotion allows the other's discount_class. If you omit combines_with, the promotion combines with every class (order, line_item, and service_charge) by default, up to max_promotions_per_order; set it explicitly to restrict stacking rather than to enable it.
exclusivity.group limits a set of promotions to one winner. It is a free-form string you define, matched by exact equality, so keep the naming consistent (a typo like bogo vs. BOGO splits one group into two and lets both promotions apply). Within a group, exclusivity.selection is either best_of (the highest standalone amount wins) or highest_priority (the highest exclusivity.priority wins; larger numbers win).
stacking_mode: "stop_after" stops later promotion candidates after that promotion is admitted. Merchant and checkout settings can also disable automatic promotions, disable promotion codes, or cap max_promotions_per_order.
Pending promotion discounts are dynamic until payment. If the order changes, Flint recalculates eligible pending promotions, removes candidates that no longer qualify, and admits newly eligible automatic promotions using the same conflict rules. Most integrations do not need to trigger this themselves; it runs automatically on every order mutation. If you need an explicit refresh before a final review screen, call POST /v1/orders/{order_id}/discounts/reprice. After payment, redeemed discounts are frozen for settlement and refunds.
Multi-currency#
percent_off is currency-agnostic and works in any order currency. amount_off is denominated in amount_off_money.currency. To support other currencies, add a currency_options map keyed by currency code:
"application_method": {
"type": "amount_off",
"amount_off_money": { "amount": 1000, "currency": "USD" },
"currency_options": { "EUR": { "amount": 900, "currency": "EUR" } }
}
An order whose currency is neither amount_off_money.currency nor present in currency_options does not receive the discount; the promotion is declined with currency_mismatch rather than converted. The same applies to money-valued rule thresholds (order.subtotal), which can carry their own currency_options.
