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. Setfalseto 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— whentrue, 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'svalid_until(the earlier of the two wins).minimum_amount_cents— minimum charge amount for this code (distinct from the coupon'sminimum_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/invoicesacceptspromotion_codes[]. Most idiomatic surface for subscription billing. - ChargeIntents (V1-deprecated but functional):
POST /v1/charge_intentsacceptspromotion_codes[]. See apply a discount at checkout. - Subscriptions (indirectly): subscriptions don't accept
promotion_codeson 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:
- The code exists and is
active: true. - Parent coupon is
activeand within itsvalid_from/valid_untilwindow. - Code is within its own
expires_atwindow if set. - Global
max_redemptions(both code-level and coupon-level) hasn't been hit. - Per-customer
max_customer_redemptionshasn't been hit for the redeeming customer. - If
customer_idoraccount_idscoping exists, the redeemer matches. - If
first_time_transaction: true, the customer has no prior successful charges. - The charge amount meets both
minimum_amount_cents(code) andminimum_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
- Coupon —
promotion_code.coupon_id. The parent rule. - Customer / Account — optional scoping via
customer_idoraccount_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.