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:

FieldPurpose
application_methodWhat discount to calculate, such as percent off, amount off, or buy-X-get-Y.
discount_classWhat the discount targets: order, line_item, or service_charge.
eligibility_rulesConditions the order, customer, or line items must satisfy before the promotion can apply.
combines_with, exclusivity, stacking_modeHow 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.

Bash
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.

Bash
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:

Bash
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:

  1. Applied. The order comes back with a new entry in applied_discounts[] (its order_discount_id, customer_facing_name, and applied_money) and a lower balance.

  2. Accepted but did not win (it lost conflict resolution to a better or exclusive promotion). Still a 200 with the repriced order, and a meta.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 a promotion_declined entry whenever a code was entered but did not produce an applied_discounts[] row.

  3. Rejected. A code that is not redeemable returns a 4xx with the reason in error.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). For minimum_not_met, the error carries structured required_money, current_money, and gap_money so 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 (a 200 with a meta.warnings[] entry) rather than a 4xx. Because it can appear on either surface, read not_combinable from both error.reason and meta.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:

Bash
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.

Bash
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:

Bash
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.

JSON
{
  "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_with controls which discount classes can stack with this promotion. It is bidirectional, and omitting it means "combines with every class" (up to max_promotions_per_order), so set it to restrict stacking, not to enable it.
  • exclusivity.group limits a set of promotions to one winner. It is a free-form string matched by exact equality, so keep the naming consistent. Use best_of to keep the highest-value candidate, or highest_priority (larger priority wins) 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.

Rate this doc