Coupons

A coupon is a reusable discount template. It defines the rule — "20% off," "$10 off orders over $50," "free month for the first three billing cycles" — and lives independently of any specific customer. Customers don't redeem coupons directly; they redeem promotion codes, which wrap coupons with customer-facing strings and per-customer scoping.

The split is intentional: one coupon (the rule) can have many promotion codes (the customer-facing handles). You define SUMMER20 as a 20% coupon once and generate distinct codes for different campaigns, audiences, or customers.

Discount types

Every coupon has a discount_type:

  • percentagediscount_value is a percentage (1-100). The discount applies to the charge amount.
  • fixed_amountdiscount_value is a cents amount. A flat reduction.

For percentage coupons, the optional discount_cap_cents puts an upper bound on how much a single application can discount — useful for "20% off, max $50" promotions.

Durations

The duration field controls how a coupon behaves over time on recurring subscriptions:

  • once — discount applies a single time (to the first invoice or charge it touches).
  • repeating — discount applies for a defined number of billing periods. Pair with duration_in_months (or the equivalent period field) to set the count.
  • forever — discount applies to every cycle indefinitely.

For one-time purchases, duration is effectively always "once" since there's only one charge to apply against. For subscriptions, choose carefully — forever discounts permanently lower the customer's bill, which has long-tail revenue implications.

Fields

curl --request POST \
  --url https://api.framepayments.com/v1/coupons \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "Summer 2026 — 20% off",
    "description": "Marketing campaign coupon",
    "discount_type": "percentage",
    "discount_value": 20,
    "duration": "once",
    "applicable_to": "all_products",
    "max_redemptions": 1000,
    "valid_from": "2026-06-01T00:00:00Z",
    "valid_until": "2026-08-31T23:59:59Z",
    "minimum_order_amount_cents": 5000,
    "discount_cap_cents": 5000,
    "is_stackable": false
  }'

The interesting fields:

  • name — internal-facing name for dashboard navigation. Customers see the promotion code string, not this name.
  • discount_type + discount_value — see above.
  • durationonce / repeating / forever. With repeating, pair with a duration period field.
  • applicable_to — scope of the coupon. all_products (any product) or specific_products (restricted via product_id).
  • max_redemptions — global cap on total redemptions across all promotion codes derived from this coupon. Hits the cap → all derived codes stop accepting redemptions.
  • valid_from / valid_until — time window. Outside this window the coupon (and all its codes) reject application.
  • minimum_order_amount_cents — coupon only applies if the order subtotal meets this threshold.
  • discount_cap_cents — upper bound on the discount value (relevant for percentage coupons on large orders).
  • is_stackable — whether this coupon can be combined with other coupons on the same charge/invoice. Non-stackable coupons fail validation if other codes are also applied.

Lifecycle

Coupons carry a status state machine:

  • active — available for new promotion codes to derive from, and existing codes can redeem against it.
  • inactive — paused. New codes can't redeem; existing redemptions stand.
  • archived — kept for historical reporting but not in active use.
  • deleted — soft-deleted. Recoverable via restore back to inactive.

Transitions:

  • active ↔ inactive via activate / deactivate.
  • active | inactive → archived via archive.
  • active | inactive | archived → deleted via discard.
  • deleted → inactive via restore.

Stacking

When multiple promotion codes apply to the same invoice or charge, they're applied sequentially in the order supplied. Each percentage discount applies to the already-discounted amount, not the original. Two 10% codes don't equal 20% off — they equal 19% off (90% × 90% = 81%).

Plus, every coupon's is_stackable flag gates whether it can participate at all. If any coupon in the applied set has is_stackable: false, the stack fails validation.

The merchant's max_discounts ceiling sets a hard cap on how many codes can apply to a single charge regardless of stacking rules. If you need a higher ceiling than the default, contact Frame support.

Relationships

  • PromotionCodepromotion_code.coupon_id is the FK. A coupon can have many promotion codes; each code is a customer-facing redemption handle.
  • Product — if applicable_to = "specific_products", coupon.product_id scopes the coupon to a specific product.
  • Invoice — coupons apply to invoices via promotion codes; discount_amount_cents and discount_count land on the invoice record.
  • ChargeIntent — same pattern on the deprecated ChargeIntent surface (apply a discount at checkout).

Gotchas

Symptom: you created a coupon and tried to apply it directly to a charge — got an error. Why: coupons aren't redeemed directly. They're the template. You need to create a promotion code from the coupon, then pass the promotion code's string at checkout. Fix: POST /v1/promotion_codes with coupon_id set, then pass the resulting code string on the charge/invoice.

Symptom: forever coupon on a subscription is still discounting after you "expired" the campaign. Why: valid_until controls when new redemptions are accepted, not when existing redemptions stop applying. Once a forever coupon is attached to a subscription, it continues until the subscription itself ends or you actively detach it. Fix: if you need to end a forever discount mid-subscription, you'll need to update the subscription's pricing structure (potentially via product phases or by canceling + recreating).

Symptom: you stacked two 50% coupons and the charge wasn't free. Why: sequential application — 50% off, then 50% off the already-discounted amount = 75% off, not 100%. Fix: if you need "two 50% codes = 100% off" semantics, model it as a single 100% coupon. Sequential math is intentional; it prevents accidental free-orders from coupon stacking errors.

Symptom: coupon hits its max_redemptions and all derived codes stop working at once. Why: max_redemptions is a global counter at the coupon level, shared across all derived promotion codes. Fix: expected behavior. If you need higher capacity, raise max_redemptions on the coupon (subject to your business policy).

Reference

For the full API surface, see POST/v1/coupons and the rest of the Coupons resource.