Promotions
Promotions are Flint's discount rule engine. Use them when you need automatic discounts, multi-code campaigns, buy-X-get-Y offers, customer or order eligibility, and conflict control between discounts.
Use Coupons for a simple reusable code with a percent or amount discount. Coupons use the same engine underneath, but keep the API shape intentionally small. Use Promotions when the discount needs rules, automatic application, multiple codes, or precise stacking behavior.
Model A Promotion#
A promotion has four core parts:
| Field | Purpose |
|---|---|
application_method | What discount to calculate, such as percent off, amount off, or buy-X-get-Y. |
discount_class | What the discount targets: order, line_item, or service_charge. |
eligibility_rules | Conditions the order, customer, or line items must satisfy before the promotion can apply. |
combines_with, exclusivity, stacking_mode | How this promotion behaves around other promotion discounts. |
Percent values are whole-number percents in the REST API. Send "percent_off": 20 for 20 percent off.
Create An Automatic Discount#
Automatic promotions are evaluated during order pricing. This example discounts hats by 20 percent whenever the order has matching line items.
curl -X POST https://api.withflintpay.com/v1/promotions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: promo-hats-20" \
-d '{
"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"] }
]
}
}'
Automatic promotions only apply when promotion settings allow automatic evaluation. Checkout-specific promotion settings can also override the merchant defaults for hosted checkout flows.
Create A Code Campaign#
Use a code promotion when a buyer or your server must provide a code. Codes are child resources under the promotion, which lets one campaign have many independently expiring or capped codes.
curl -X POST https://api.withflintpay.com/v1/promotions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: promo-vip" \
-d '{
"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 promotion code to an order through the order discounts API:
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/discounts \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: apply-vip10" \
-d '{
"promotion": { "promotion_code": "VIP10" }
}'
The apply endpoint accepts either promotion_code or promotion_id. Use promotion_code when a buyer redeems a campaign code, and promotion_id for server-side apply flows where you already know the promotion. The body takes exactly one of coupon, promotion, or manual. The full repriced order comes back under data, with the new discount as one element of data.applied_discounts[].
Reading the apply result#
There are three outcomes, and you branch on all three:
Applied. The order comes back with a new entry in
applied_discounts[](itsorder_discount_id,customer_facing_name, andapplied_money) and a lower balance.Accepted but did not win (it lost conflict resolution to a better or exclusive promotion). Still a
200with the repriced order, and ameta.warnings[]entry describes what happened:JSON{ "data": { "order_id": "ord_1kmn0aExample", "applied_discounts": [ /* winning discounts, not VIP10 */ ] }, "meta": { "warnings": [ { "code": "promotion_declined", "reason": "superseded_by_better_offer", "promotion_code": "VIP10", "would_have_applied_money": { "amount": 1000, "currency": "USD" } } ] } }Check
meta.warnings[]for apromotion_declinedentry whenever a code was entered but did not produce anapplied_discounts[]row.Rejected. A code that is not redeemable returns a
4xxwith the reason inerror.reason, not a warning. This covers a typo (code_invalid), an expired or exhausted code, a not-yet-started or disabled promotion, a currency mismatch, and an unmet spend minimum (minimum_not_met). Forminimum_not_met, the error carries structuredrequired_money,current_money, andgap_moneyso you can render "needs $50, you have $40, add $10" without parsing the message.A class conflict with an existing discount (
not_combinable) is a conflict outcome, not a hard rejection, so it usually surfaces as outcome 2 (a200with ameta.warnings[]entry) rather than a4xx. Because it can appear on either surface, readnot_combinablefrom botherror.reasonandmeta.warnings[].reason.
In short: conflict losses are a 200 with meta.warnings[]; anything that makes the code non-redeemable is a 4xx error. Branch buyer-facing copy on error.reason (4xx) or meta.warnings[].reason (200), and keep the human message only as a fallback. See the Promotions reference for the full reason list.
Removing a discount#
Get the discount's order_discount_id from the order's applied_discounts[] array (it has no prefix and is unique within the order), then remove it:
curl -X DELETE https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/discounts/ORDER_DISCOUNT_ID \
-H "Authorization: Bearer YOUR_API_KEY"
Preview Before Applying#
Preview discounts when your UI needs to show why a code did or did not apply, or when you want to nudge a buyer toward a minimum purchase threshold.
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/discounts/preview \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"discount": {
"promotion": { "promotion_code": "VIP10" }
}
}'
Preview returns applied, skipped, and available candidates. skipped[] explains every promotion that could have applied but did not, each with a promotion_decline_reason and would_have_applied_money. available[] surfaces close-but-unmet automatic promotions whose only missing condition is a single order.subtotal minimum, with threshold_money, current_money, and gap_money so your UI can say how much more the buyer needs to add. (A promotion with a compound minimum, or a code the buyer has not entered yet, will not appear in available[] with a gap.)
Note the body shape differs between the two endpoints. Apply takes a flat discount body ({ "promotion": { ... } }); preview wraps the same object in discount ({ "discount": { "promotion": { ... } } }) because preview describes a proposed change without mutating the order. Both endpoints reject unknown fields, so copying an apply body straight into preview (or vice versa) returns a 400; add or remove the discount wrapper accordingly.
Reprice Discounts#
Flint automatically recalculates pending discounts when an order changes. If your integration changes order state outside the normal order mutation flow, or wants an explicit refresh before showing a final review screen, call reprice:
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/discounts/reprice \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: repr_1kmn0aExample"
Reprice returns the full order after pending discounts and automatic promotions are recalculated. It does not create a new promotion engine path; it runs the same recalculation logic Flint uses after order mutations.
Build Buy-X-Get-Y Offers#
Buy-X-get-Y promotions define the qualifying items, the rewarded items, and the reward selection strategy. This example makes the cheapest matching tee free when the buyer has at least two tees in the cart.
{
"name": "Buy two tees, get one free",
"display_name": "Buy 2 tees, get 1 free",
"redemption_type": "automatic",
"discount_class": "line_item",
"application_method": {
"type": "buy_x_get_y",
"qualifying_item_rules": [
{ "attribute": "line_item.category", "operator": "eq", "values": ["tees"] }
],
"buy_min_quantity": 2,
"discounted_item_rules": [
{ "attribute": "line_item.category", "operator": "eq", "values": ["tees"] }
],
"get_quantity": 1,
"get_percent_off": 100,
"reward_selection": "cheapest",
"max_applications_per_order": 1
}
}
Use max_applications_per_order when a promotion should apply only once even if the cart qualifies multiple times.
Handle Conflicts#
Flint evaluates manual discounts first. Promotion candidates are then evaluated together, including existing pending promotion discounts and newly eligible automatic promotions.
Conflict rules are explicit:
combines_withcontrols which discount classes can stack with this promotion. It is bidirectional, and omitting it means "combines with every class" (up tomax_promotions_per_order), so set it to restrict stacking, not to enable it.exclusivity.grouplimits a set of promotions to one winner. It is a free-form string matched by exact equality, so keep the naming consistent. Usebest_ofto keep the highest-value candidate, orhighest_priority(largerprioritywins) when your campaign priority should decide.stacking_mode: "stop_after"lets one admitted promotion stop later promotion candidates.- Merchant or checkout settings can disable automatic promotions, disable promotion codes, or cap
max_promotions_per_order.
When a promotion is redeemable but simply loses conflict resolution, order create and discount apply return a successful 200 order response with the reason in meta.warnings[]. Treat that as "the code was valid, but did not win." Losing on combines_with (not_combinable) is one of these conflict outcomes, so it usually arrives as a 200 warning too. A code that is not redeemable at all (malformed, disabled, expired, exhausted, currency mismatch, or unmet minimum) returns a 4xx error with the reason in error.reason instead. See Reading the apply result for the full breakdown.
REST And Proto Clients#
The public REST API uses the flattened JSON shape shown in this guide and in the OpenAPI schema. Generated protobuf TypeScript clients model application_method as the internal proto oneof shape, for example effect: { case: "percentOff", value: ... }. Use OpenAPI-generated REST clients or the examples in the REST docs when calling https://api.withflintpay.com.
