Discounts with coupons
Create a percentage or fixed-amount code, decide how long it lasts, and apply it either yourself or by letting the buyer type it.
A coupon is a code that takes money off a checkout. Two things decide what it is worth: how much it discounts, and how long it keeps discounting. Get the second one right first, because it is the one that shows up in your revenue numbers months later.
Create one
curl https://api.billkit.eu/v1/coupons \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "LAUNCH20",
"percent_off": 20,
"duration": "repeating",
"duration_in_months": 3,
"max_redemptions": 500,
"min_amount_cents": 1000
}'| Field | Notes |
|---|---|
| coderequired | Up to 64 characters. Stored upper-case with surrounding whitespace stripped, so launch20 and LAUNCH20 are the same code. Two coupons cannot share one, and a duplicate is a 400. |
| percent_off | A percent, more than 0 and at most 100, with up to two decimal places: 20 is 20% off and 12.5 is 12.5% off. A third decimal place is refused rather than rounded. Send this or amount_off_cents, never both. |
| amount_off_cents | A fixed amount off, in minor units of currency, so 500 is €5.00 off a euro price. |
| discount_type | percent or fixed_amount. Optional: the field you send implies it, and if you send both they must agree. |
| duration | once (default), repeating or forever. |
| duration_in_months | Required with repeating, 1 to 120. |
| max_redemptions | Total claims across all customers. Unlimited when null. |
| redeem_by | Unix seconds. After this the code is refused. |
| applies_to_price_ids | Restrict it to named prices. Applies to everything when null. |
| min_amount_cents | Order minimum, checked before the discount. |
| currency | The currency of amount_off_cents and min_amount_cents; the coupon then applies only to prices in it. Required with either when your account has more than one currency enabled, otherwise your one currency. |
The coupon object carries both percent_off and amount_off_cents, and exactly one of them is
not null; discount_type says which. A percent discount is worked out exactly and rounded
half-up to a whole minor unit, once, on the amount of the charge it discounts: 12.5% off €9.99 is
124.875 cents and takes €1.25 off, and 12.5% off ¥999 takes ¥125, because yen has no smaller unit. A discount can take the
charge to zero, so a 100%-off “first month free” code costs the customer nothing. The first
charge is then raised to the smallest amount every payment method on the price accepts: zero for
a card-only price, one cent with iDEAL, €1.00 with EPS.
Duration is the field that matters
| duration | What it discounts | Effect on MRR |
|---|---|---|
| once | The first charge only. | None. Every renewal after it is at list price. |
| repeating | Every charge dated within duration_in_months of the first one it applies to. | Reduced while it still applies. |
| forever | Every charge, indefinitely. | Reduced permanently. |
duration_in_months is a window of time, not a number of charges. It opens at the first charge
the coupon discounts, and every charge dated inside it is discounted. A repeating coupon with
duration_in_months: 3 on a monthly plan discounts three charges and the fourth is at list price,
wherever the customer picked it up: at checkout that is the first payment plus two renewals, and
entered in the customer portal on a live subscription it is the next three charges. On a yearly
plan the same code discounts only the first charge, because the next one is twelve months out;
duration_in_months: 13 reaches the first yearly renewal as well. On a trial the window opens at
the first real charge, when the trial ends. This matches Stripe, so a coupon ported across behaves
the same on both.
A plan change does not move the window. The proration charge is discounted if it falls inside it, and so are the new plan’s renewals, up to the same end date.
A charge is dated by the start of the period it pays for, not by when it is attempted. A renewal that fails and is retried after the window has closed is still discounted, because its period started inside the window.
When the last discounted charge is paid and the next period starts outside the window, the binding
is cleared and subscription.coupon_expired fires, so the next renewal is at full price and your
own systems can see it coming. It fires with the payment that used the discount up, not when the
window’s end date passes, so acting on it never means mailing a customer about a discount they are
still receiving.
A renewal that a 100% coupon brings to zero is not sent to Mollie at all. BillKit settles the period itself and issues a paid invoice for zero.
A €79/year plan bought with a 99%-off once code still contributes €6.58/month to MRR: the
customer paid €0.79 for their first year and pays €79 for every year after, and MRR is what recurs. The
same code set to forever contributes 7 cents. See
MRR and discounts.
Applying one
There are three routes in, and they are wired differently.
You decide who gets it. Pass coupon_code when you create the checkout session. This is the
right tool for a staff comp, a negotiated rate or an apology credit, and it needs no opt-in.
curl https://api.billkit.eu/v1/checkout/sessions \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_email": "ada@example.com",
"price_id": "price_7Hd3Wm9pQx2vRt5Kna",
"coupon_code": "LAUNCH20",
"success_url": "https://example.com/welcome",
"cancel_url": "https://example.com/pricing"
}'The buyer types it. Off by default, and switched on per product with
allow_promotion_codes: true on the product, or in the console under
Products → (product) → Edit. The embedded element then shows a collapsed “Add promo code”
link above the pay button. Leave it off for products that run no public promotion, because a
promo field on everything you sell invites code-guessing across the catalogue. See
Embed Checkout.
The customer enters it in the portal. Off by default, and it takes two switches: the
customer portal’s coupons capability, and
allow_promotion_codes: true on the subscription’s product, the same switch the embedded checkout
honours. With both on, the portal shows a coupon field, and a code applies to an active,
trialing or past_due subscription. With the capability off the portal refuses every code; on a
product without the switch the field is hidden and every code is refused alike with
coupon_not_applicable. Rejected codes
are limited to 10 per subscription, 20 per customer and 2,000 per account and mode in 24 hours,
across every portal session, and past that the portal answers 429 rate_limited. Only a code that
does not exist or does not apply counts; a code that is accepted never does, so a promotion used by
many subscribers does not use up the limit. The same eligibility rules apply, the
redemption is claimed straight away, and a subscription carries one coupon at a time.
The discount starts at the next charge BillKit has not yet attempted: on a past_due subscription
the unpaid period keeps the amount it was first charged, and the discount starts with the period
after it.
Check a code before charging for it
POST /v1/coupons/validate answers whether a code is usable and what it would take off. It
changes nothing and consumes no redemption, so it is safe to call on every keystroke.
curl https://api.billkit.eu/v1/coupons/validate \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"code": "LAUNCH20", "price_id": "price_7Hd3Wm9pQx2vRt5Kna", "amount_cents": 2900}'{
"object": "coupon_validation",
"valid": true,
"coupon_id": "coup_Hn4Tz7qR2wM9xKcVbp",
"code": "LAUNCH20",
"discount_type": "percent",
"percent_off": 20,
"amount_off_cents": null,
"currency": "EUR",
"duration": "repeating",
"discount_cents": 580,
"final_charge_cents": 2320,
"reason": null
}Send amount_cents to get the money back as well; without it you learn only whether the code is
usable. A bad code is a 200 with valid: false and a reason, not an error, because a mistyped
coupon is ordinary input.
| reason | Meaning |
|---|---|
| coupon_not_found | No coupon has this code. |
| coupon_inactive | The coupon has been withdrawn with active: false. |
| coupon_expired | Past redeem_by. |
| coupon_price_mismatch | price_id is not in applies_to_price_ids. |
| coupon_below_minimum | amount_cents is under min_amount_cents. |
| coupon_exhausted | max_redemptions reached. |
| coupon_currency_mismatch | The coupon is in a different currency from the price, or from currency when no price_id is sent. |
A checkout that refuses a code speaks a coarser vocabulary. It returns a 400 on coupon_code
whose reason is one of coupon_invalid (unknown or withdrawn), coupon_expired,
coupon_exhausted or coupon_not_applicable (wrong price, wrong currency or under the minimum). A
code the buyer types into the embedded element is the exception: every refusal there is
coupon_invalid, so the element does not tell a guesser which codes exist. Map both sets to
your own copy, and fall back to a generic message for any value you do not recognise.
When a redemption is actually spent
A coupon’s redemption is claimed when the payment settles, not when the session is created, and not when the buyer presses pay.
So a shopper can type a single-use code, see the discounted price, and close the tab without consuming it. A declined card does not consume it either. This is what makes a one-redemption code usable at all: under the obvious implementation the first person to abandon checkout burns it permanently.
A cap is not a hard guarantee under contention. Between the eligibility check and settlement, a
concurrent checkout can take the last slot. The buyer who has already been charged keeps their discount, so a
capped coupon can finish one or two over its limit, and redemptions_count can exceed
max_redemptions. Size the cap with that in mind rather than treating it as an inventory count.
Renewals do not claim a further redemption. The original claim is what counts, and the subscription’s binding is what discounts the charges after it.
Changing and withdrawing a coupon
POST /v1/coupons/{id} changes only what has not already been promised: active,
max_redemptions, redeem_by, applies_to_price_ids and min_amount_cents. Send one of the last
four as null to lift that restriction; active: null is refused with a 400. The discount itself
and its duration are fixed at creation, because a customer already carrying the coupon is being
billed against them.
curl https://api.billkit.eu/v1/coupons/coup_Hn4Tz7qR2wM9xKcVbp \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"active": false}'active: false withdraws the code from new checkouts. Customers already carrying it keep their
discount, which is the behaviour you want: withdrawing a campaign should not re-price the people
it brought in. There is no delete, and GET /v1/coupons returns withdrawn coupons too, so check
active before showing a code as available.
Where coupons do not apply
| Case | Result |
|---|---|
| Metered prices | Refused at checkout. There is no fixed period fee to discount. |
| Plan changes | A proration quote takes no coupon code. |
| One-off payments | No coupon field. Send the discounted amount. |
| Refunds | A refund returns what was charged, which is already net of the discount. |
Events and the audit trail
coupon.created and coupon.updated cover the coupon itself, including active: false
withdrawing a code. On the subscription side, subscription.coupon_applied fires when a discount
attaches and subscription.coupon_expired when a repeating one runs out.
Individual redemptions are audit log entries, not events. GET /v1/audit_logs is where you
see who redeemed what, which is a different question from what your application needs to react to.
BillKit