Promotion codes

A promotion code is the customer-facing string that maps to a coupon. When a customer enters SUMMER20 at checkout, your platform resolves that string to a PromotionCode, which references its parent Coupon, which carries the actual discount rule. The split lets one coupon serve many customer-facing strings — for example, distinct codes per channel, per audience, or per individual customer.

Coupon vs promotion code, in practice

  • The coupon says what the discount is: "20% off, valid June–August, max 1,000 redemptions globally, applies to all products."
  • The promotion code says who can use it and how: "the string is SUMMER20, scoped to no specific customer, max 1 redemption per customer."

A single coupon can have one code (SUMMER20 for everyone) or many codes (one per influencer partner: SUMMER20-ALEX, SUMMER20-JORDAN, etc.). Same underlying discount; different attribution and limits.

Fields

curl --request POST \
  --url https://api.framepayments.com/v1/promotion_codes \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "code": "SUMMER20",
    "coupon_id": "<coupon_id>",
    "active": true,
    "max_redemptions": 500,
    "max_customer_redemptions": 1,
    "first_time_transaction": false,
    "expires_at": "2026-08-31T23:59:59Z",
    "minimum_amount_cents": 5000
  }'

The interesting fields:

  • code — the customer-facing string. Frame normalizes to uppercase on save, so case in the request doesn't matter at redemption time. Must be unique within a merchant.
  • coupon_id — the parent coupon. The discount rule, validity window, and discount-type all live there.
  • customer_id / account_id — optional, mutually exclusive. Scope the code to a single customer or account. Useful for win-back campaigns and personalized offers; leave both null for a publicly-redeemable code.
  • active — toggle. Set false to take the code out of circulation without deleting it.
  • max_redemptions — global cap on uses of this specific code (distinct from the parent coupon's global cap, which spans all derived codes).
  • max_customer_redemptions — per-customer cap. 1 = single-use per customer.
  • first_time_transaction — when true, Frame rejects the code if the customer has any prior successful charges on the platform.
  • expires_at — code-specific expiration. Doesn't override the coupon's valid_until (the earlier of the two wins).
  • minimum_amount_cents — minimum charge amount for this code (distinct from the coupon's minimum_order_amount_cents; both must be satisfied).

Where promotion codes apply

Promotion codes flow into charges and invoices via the promotion_codes[] array on the create call:

  • Invoices: POST /v1/invoices accepts promotion_codes[]. Most idiomatic surface for subscription billing.
  • ChargeIntents (V1-deprecated but functional): POST /v1/charge_intents accepts promotion_codes[]. See apply a discount at checkout.
  • Subscriptions (indirectly): subscriptions don't accept promotion_codes on create. To attach a discount to a subscription, apply the promotion code to the invoice that the subscription generates — usually by including it on the invoice create request or by attaching it to the subscription's auto-generated invoices via the dashboard.

The Transfer create surface doesn't currently accept promotion_codes directly. If you need discounts on a transfer, route through ChargeIntent until parity lands.

Validation at redemption

When a code lands on a charge/invoice, Frame validates:

  1. The code exists and is active: true.
  2. Parent coupon is active and within its valid_from / valid_until window.
  3. Code is within its own expires_at window if set.
  4. Global max_redemptions (both code-level and coupon-level) hasn't been hit.
  5. Per-customer max_customer_redemptions hasn't been hit for the redeeming customer.
  6. If customer_id or account_id scoping exists, the redeemer matches.
  7. If first_time_transaction: true, the customer has no prior successful charges.
  8. The charge amount meets both minimum_amount_cents (code) and minimum_order_amount_cents (coupon).

Any failure rejects the redemption with a descriptive error. For graceful handling of these errors at checkout, see apply a discount at checkout.

Common shapes

Single public code. One coupon, one code with no customer scoping, generous max_redemptions. Market it on your platform.

Per-customer single-use code. Coupon defines the discount; per-customer code with customer_id set and max_customer_redemptions: 1. Generate at win-back time and email the code to the specific customer.

Channel attribution. Same coupon, distinct codes per channel (LAUNCH-EMAIL, LAUNCH-SOCIAL, LAUNCH-PARTNER). Same discount applies regardless of which code; you can tell from your reporting which channel converted.

First-time-buyer code. first_time_transaction: true with no customer scoping. Public code; only first-charge customers can redeem.

Influencer partnerships. One code per influencer (MARCIA20, JORDAN20). Track redemptions per code for attribution + payouts.

Relationships

  • Couponpromotion_code.coupon_id. The parent rule.
  • Customer / Account — optional scoping via customer_id or account_id.
  • Invoice / ChargeIntent — the surfaces that accept promotion codes on create.

Gotchas

Symptom: SUMMER20 keeps getting rejected even though the coupon is active. Why: one of the eight validation checks above is failing. The most common: per-customer cap hit (max_customer_redemptions: 1 and the customer already used it), or first_time_transaction: true against a customer with prior charges, or amount below the minimum. Fix: the API response includes the specific validation failure; inspect and surface accordingly.

Symptom: you set both code-level and coupon-level max_redemptions and the code stopped working before either was visibly hit. Why: both counters tick independently and whichever hits first stops redemption. The coupon-level counter aggregates across all derived codes, so it can fill from other codes' usage. Fix: check both counters via the API; raise whichever is the binding constraint.

Symptom: the customer typed summer20 lowercase and the redemption failed. Why: promotion code matching is case-sensitive at the API layer, but Frame normalizes to uppercase on save. If your application is doing exact-string matching before sending to Frame, normalize client-side. Fix: uppercase the customer's input before sending; Frame will then accept it.

Symptom: you scoped a code with customer_id but the redemption is going through for other customers. Why: this shouldn't happen if customer_id is set on the code. If you're seeing it, double-check the code's stored customer_id via GET /v1/promotion_codes/<id>. Fix: if the field is null, your create request didn't include it — re-create the code with the scoping fields set.

Reference

For the full API surface, see POST/v1/promotion_codes and the rest of the PromotionCodes resource.