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 independent child resources, so one promotion can have many independently expiring codes or no codes at all.
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.categories", "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.categories | category handle | eq, ne, in, is_defined | Matches the line item's catalog categories. ne means none of its categories match. |
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.
List promotion codes#
Call GET /v1/promotion-codes to find a code when its parent promotion is unknown, or to list a promotion's codes with promotion_id. The code filter is an exact match that ignores case and accents: CAFÉ matches cafe. This is the same rule that makes promotion codes unique. Codes preserve their original spelling. Leading and trailing whitespace are rejected, not trimmed.
curl 'https://api.withflintpay.com/v1/promotion-codes?code=summer10&expand=promotion' \
-H "Authorization: Bearer YOUR_API_KEY"
Each item in data is a promotion code. expand=promotion adds its parent promotion without removing promotion_id; both the list and expansion require commerce.promotions.read. The list includes every code status: active, inactive, expired, and exhausted. Read status to determine whether the code is active, or filter by exactly one status. Deleted codes are excluded. A code that does not exist returns an empty array.
Results are ordered by created_at descending, then promotion_code_id descending. page_size defaults to 20 and accepts 1 through 100. Pass next_page_token as page_token for the next page, keeping the other parameters unchanged.
Create a discount preview#
Call POST /v1/discount-previews before applying a code to show its expected discount or an unmet purchase minimum. Supply the required order_id and an optional discount. Omit discount to evaluate automatic promotions for the order.
curl -X POST https://api.withflintpay.com/v1/discount-previews \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"order_id":"ord_01HZYV7Q9Y0Y4H8Q3H9F6R7T8J","discount":{"promotion":{"promotion_code":"SUMMER10"}}}'
The response is 200 OK with applied, skipped, and available arrays directly inside data. The preview does not change the order, create an addressable resource or ID, or require an idempotency key. It uses commerce.orders.read and also accepts the order's checkout session credentials. Merchant credentials can select a promotion by ID or code; checkout session credentials must select by code.
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.categories", "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, and list a promotion's codes with GET /v1/promotion-codes?promotion_id=promo_1kmn0aExample; 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#
Apply promotions through POST /v1/orders/{order_id}/discounts, using a discount body with exactly one of promotion or manual:
{ "promotion": { "promotion_code": "VIP10" } }
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 promotion discounts because the promotion 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 is inactive. |
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, inactive, expired, not_yet_started, exhausted, no_active_codes, or archived. no_active_codes means a code-gated promotion has no usable codes; exhausted means max_uses is reached.
A subscription uses a promotion once, when it starts. Its renewals don't count toward max_uses, so reaching the limit stops new signups but never refuses a renewal. A renewal gets the discount only as the promotion's recurrence allows.
GET /v1/promotions?status= filters on the returned value and matches any of several: repeat the parameter or separate values with commas. ?status=active,no_active_codes lists every promotion that is switched on and inside its dates, including code-gated ones that currently have no usable code. ?status=expired,exhausted lists the ones that are over. A page token belongs to the status set that issued it, so start again from the first page when the set changes.
A code-gated promotion also returns codes_summary, so a list can show how buyers redeem each promotion without fetching every promotion's codes:
"codes_summary": { "total_count": 3, "active_count": 2, "newest_active_code": "SUMMER10" }
total_count counts the promotion's codes, leaving out deleted ones. active_count counts the codes a buyer can redeem now: active, not expired, and under their max_uses. When it is 0 the promotion's status is no_active_codes, and newest_active_code is omitted. Automatic promotions have no codes and omit codes_summary.
PATCH /v1/promotions/{id} accepts only active or inactive 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: archived (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.
