Apply a discount at checkout
Frame's discount system has two primitives: coupons (reusable discount templates — 20% off, $10 off, etc.) and promotion codes (customer-facing codes generated from a coupon that customers enter at checkout). Applying the discount at the point of sale is one field on the charge call.
This guide focuses on the charge-time application. For setting up coupons and generating codes from them (typically a dashboard task done by your marketing or operations team), see the Coupons and Promotion Codes API surfaces.
API surface note (V1)
In V1, discount application lives on the ChargeIntent surface — POST /v1/charge_intents accepts a promotion_codes: [] array. The newer Transfer-canonical surface (POST /v1/transfers) does not yet accept promotion codes, so discount-bearing charges go through ChargeIntent directly.
ChargeIntent is V1-deprecated — it carries a deprecation banner on the API reference — but it's still the working surface for discount flows until the Transfer endpoint gains parity. Use it without worry; it stays wire-compatible through V1.
Prerequisites
| Requirement | Details |
|---|---|
| An active coupon | Created via POST /v1/coupons. Defines the discount rule (percentage / fixed, validity dates, usage limits). |
| A promotion code from that coupon | Created via POST /v1/promotion_codes. The customer-facing string. |
| Frame secret key | For server-side charge intent creation. |
If you don't have these yet, set them up via the API or your Frame dashboard before integrating the checkout-side logic.
1. Collect the code from the customer
Your checkout UI accepts a promotion-code input (text field, "have a discount code?" link that reveals the field, etc.). Pass the entered value to your backend along with the rest of the checkout payload.
// Client-side: capture the code from your UI
const promotionCode = document.querySelector('#promo-code').value.trim();
// Send to your backend along with cart + payment details
await fetch('/checkout', {
method: 'POST',
body: JSON.stringify({ cart, promotionCode, paymentMethodId })
});
Pre-validating the code on the client (e.g., calling Frame to check eligibility before the charge) adds latency for limited benefit. The cleanest pattern is "submit at checkout; surface error if invalid."
2. Pass the code on the charge intent
Include the promotion code in the promotion_codes array on your charge intent create call:
curl --request POST \
--url https://api.framepayments.com/v1/charge_intents \
--header "Authorization: Bearer $FRAME_SECRET_KEY" \
--header 'Content-Type: application/json' \
--data '{
"amount": 5000,
"currency": "USD",
"account_id": "<account_id>",
"payment_method_id": "<payment_method_id>",
"promotion_codes": ["SUMMER20"],
"description": "Order #1042"
}'
Frame:
- Validates each code in the array — expiration, usage limits, customer / account eligibility, minimum order amount.
- Calls
PromotionCodes::ApplyServiceper code, applied sequentially in the order supplied. - Calculates the discount per the underlying coupon's rule.
- Mutates the charge intent's
amount_centsto the discounted value; the original is preserved ingross_amount_cents.
The response is the ChargeIntent. The interesting field for discount tracking: the ChargeIntent's amount_cents has been mutated to the final post-discount amount — that's what the customer pays. The original pre-discount amount, the discount total, and the count of codes that resolved successfully live on the related Charge record (accessed via the ChargeIntent's latest_charge association — fields are gross_amount_cents, discount_amount_cents, discount_count).
{
"id": "ci_...",
"amount_cents": 4000,
...
}
If you need to surface the breakdown to your customer ("$50 - $10 promo = $40"), pull gross_amount_cents + discount_amount_cents from the Charge sub-record, not the ChargeIntent itself.
3. Handle validation failures
If a code is invalid (expired, exhausted, doesn't apply to this customer/account, minimum order not met), the charge intent creation fails. The error message is descriptive but the specific error code strings vary by failure category — handle by string match on the broad reason, or fall back to a generic "promotion code couldn't be applied" message:
try {
const chargeIntent = await createChargeIntent({ promotion_codes: ['SUMMER20'], ... });
// Success — charge proceeded with discount applied
} catch (err) {
if (err.message.toLowerCase().includes('promotion code')) {
// Surface a graceful "code couldn't be applied" message; proceed at full price
// or block checkout based on your platform's policy
showError('That promotion code could not be applied. Continuing at full price.');
} else {
showError('Checkout failed. Please try again.');
}
}
The reasoning behind soft-matching: Frame's error strings are descriptive but the exact wording can evolve. Don't tie merchant code to specific error strings; treat any promotion-code-related failure as a categorical "code didn't apply" and surface accordingly.
Stacking codes
Multiple promotion codes per charge intent are allowed, subject to your merchant's max_discounts setting (set on the Merchant level; ask Frame support if you need it raised):
"promotion_codes": ["LOYALTY10", "SUMMER20", "FREESHIP"]
Codes apply sequentially in the order supplied, with each subsequent percentage discount applying to the already-discounted amount. Coupon's is_stackable flag also controls whether a specific coupon can be combined with others — non-stackable coupons fail validation if other codes are also supplied.
If your platform has stacking rules beyond what Frame's flags enforce (e.g., "no combining percentage discounts," "shipping codes must be applied last"), enforce them on your side before passing codes to Frame.
Common variations
Customer-specific codes. Generate a promotion code scoped to a single customer (or set of customers) at creation time. When that customer attempts the code, Frame validates against the scope. Useful for win-back campaigns and personalized offers.
First-time buyer codes. Set first_time_transaction: true on the promotion code. Frame checks whether the customer has any previous successful charges on your platform and rejects the code if so.
Subscription discounts. For subscription billing rather than one-time charges, promotion codes can carry duration: repeating with a duration_in_months value (or duration: forever). The discount applies to subsequent renewal charges automatically. See the subscriptions docs when those land (Phase 4b).
Free shipping codes. Frame doesn't model "shipping" as a first-class primitive — your platform calculates shipping cost and includes it in the charge amount. A "free shipping" promo is functionally a fixed-amount discount equal to the shipping cost. Apply it as a normal fixed_amount discount with the right value.
Gotchas
Symptom: a code that worked yesterday is failing today. Why: the code may have hit its usage limit, expired, or been deactivated on the dashboard. Fix: check the promotion code's status via GET /v1/promotion_codes/<id> to see which condition failed. If exhausted, generate a new code from the same coupon.
Symptom: you applied a discount but the response shows the original amount. Why: the discount may have failed validation silently — Frame won't error the charge if no codes apply; it'll proceed at full price. Fix: always check discount_count in the response. If you sent codes but discount_count === 0, none of them resolved. Surface this to the customer ("your code couldn't be applied — checkout proceeded at full price").
Symptom: you're stacking 3 codes and getting an unexpected final amount. Why: sequential application means each subsequent percentage discount applies to the already-discounted amount, not the original. Two 10% codes don't equal 20% off; they equal 19% off. Fix: if you need additive stacking semantics ("two 10% codes = 20% off"), calculate the combined discount on your side and pass a single fixed-amount code instead.
Symptom: you want to apply discounts on a POST /v1/transfers call. Why: the Transfer surface doesn't currently accept promotion_codes; only ChargeIntent does. Fix: use the ChargeIntent endpoint for discount-bearing charges. When the Transfer surface gains parity, V1 will support both.
Next steps
- Accept a payment for the full charge-flow integration
- Coupon + promotion-code API reference for the setup side (creating reusable templates and customer-facing codes)