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)

Discounts apply on POST /v1/transfers — the canonical charge surface, and what this guide uses. It accepts a promotion_codes: [] array for charge-backed transfers only (those with a source_payment_method_id); payout-only transfers reject it. The codes apply to the underlying charge.

One thing to know up front: the Transfer response's amount / gross_amount / net_amount report the pre-discount figures — the applied discount lands on the underlying charge, not the Transfer object. So you don't read the discount back off the Transfer response. To show it to the customer, preview it with POST /v1/discounts/validate (below); to confirm the discounted amount actually charged, retrieve the underlying charge intent via the charge_intent id on the response.

The deprecated POST /v1/charge_intents surface also accepts promotion_codes at the wire level, but the field isn't part of its published schema and that path is being retired — route discount-bearing charges through Transfers.

Prerequisites

RequirementDetails
An active couponCreated via POST /v1/coupons. Defines the discount rule (percentage / fixed, validity dates, usage limits).
A promotion code from that couponCreated via POST /v1/promotion_codes. The customer-facing string.
Frame secret keyFor server-side transfer 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 })
});

You can pre-validate a code with POST /v1/discounts/validate (it accepts a publishable key, so it works client-side). Because the charge itself silently skips invalid codes — see Handle invalid codes — pre-validation is the only way to tell the customer a code was rejected; otherwise the sole signal is a higher-than-expected final amount.

2. Pass the code on the transfer

Include the promotion code in the promotion_codes array on your transfer create call. The transfer must be charge-backed — include a source_payment_method_id; payout-only transfers reject promotion_codes:

curl --request POST \
  --url https://api.framepayments.com/v1/transfers \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "amount": 5000,
    "currency": "USD",
    "account_id": "<account_id>",
    "source_payment_method_id": "<payment_method_id>",
    "promotion_codes": ["SUMMER20"],
    "description": "Order #1042"
  }'

Frame:

  1. Validates each code in the array — expiration, usage limits, customer / account eligibility, minimum order amount.
  2. Applies each code sequentially in the order supplied.
  3. Calculates the discount per the underlying coupon's rule.
  4. Applies the discount to the underlying charge — the customer is charged the discounted total.

The response is the Transfer. Note that its amount, gross_amount, and net_amount report the pre-discount figures — the discount applies to the underlying charge, not the Transfer object, so those fields look the same whether or not a code resolved:

{
  "id": "...",
  "object": "transfer",
  "status": "pending",
  "amount": 5000,
  "gross_amount": 5000,
  "charge_intent": "...",
  ...
}

So you can't read the discount back off the Transfer. To confirm the amount actually charged, retrieve the underlying charge intent (GET /v1/charge_intents/<charge_intent> — its amount reflects the discount). To surface the breakdown to your customer ("$50 − $10 promo = $40"), compute it with POST /v1/discounts/validate before charging — it returns each code's validity and discount_amount_cents along with a total_discount_amount_cents, and accepts a publishable key for client-side use.

3. Handle invalid codes

If a code is invalid (expired, exhausted, doesn't apply to this customer/account, minimum order not met), Frame silently skips it — the charge still proceeds, applying whatever codes did resolve, or at full price if none did. It does not raise an error. So an unapplied promo code looks like a successful charge at a higher-than-expected amount, not an exception.

Because the charge won't throw, detect non-application one of two ways:

  • Pre-validate with POST /v1/discounts/validate before charging. It returns each code's valid flag and discount_amount_cents plus a total_discount_amount_cents. Surface "code couldn't be applied" from that result, then charge. This is the cleanest pattern and works with a publishable key for client-side checks.
  • Compare amounts after charging: if the response amount equals your full pre-discount total, no code resolved.
// Pre-validate, then charge
const { validation_result } = await validateDiscounts({
  amount_cents,
  promotion_codes: ['SUMMER20'],
  customer_id,
});

const rejected = validation_result.filter((r) => !r.valid);
if (rejected.length) {
  showError('That promotion code could not be applied. Continuing at full price.');
}

// Proceed with the charge — Frame skips any invalid codes regardless
await createTransfer({ promotion_codes: ['SUMMER20'], source_payment_method_id, ... });

Pre-validating is the only way to tell the customer why a code didn't apply: the charge call itself gives no signal beyond the final amount.

Stacking codes

Multiple promotion codes per transfer 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 code but can't tell from the Transfer response whether it worked — the amount looks full-price. Why: two things compound. The Transfer response always reports pre-discount amounts (the discount is on the underlying charge), and invalid codes are silently skipped — so a full-price-looking Transfer response is expected and tells you nothing about whether a code resolved. There's no discount_count field to check. Fix: pre-validate with POST /v1/discounts/validate to learn each code's validity before charging, or retrieve the underlying charge intent (the charge_intent id), whose amount reflects the actual discounted charge.

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: promotion_codes is rejected on your POST /v1/transfers call. Why: promotion codes are only supported on charge-backed transfers — those with a source_payment_method_id. A payout-only transfer rejects them. Fix: include a source_payment_method_id (the payment method to charge).

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)