BillKit/DocsConsole
Payments guide

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.

24 Sept 2026·Beginner·9 min read

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

shell
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
}'
FieldNotes
coderequiredUp 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_offA 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_centsA fixed amount off, in minor units of currency, so 500 is €5.00 off a euro price.
discount_typepercent or fixed_amount. Optional: the field you send implies it, and if you send both they must agree.
durationonce (default), repeating or forever.
duration_in_monthsRequired with repeating, 1 to 120.
max_redemptionsTotal claims across all customers. Unlimited when null.
redeem_byUnix seconds. After this the code is refused.
applies_to_price_idsRestrict it to named prices. Applies to everything when null.
min_amount_centsOrder minimum, checked before the discount.
currencyThe 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

durationWhat it discountsEffect on MRR
onceThe first charge only.None. Every renewal after it is at list price.
repeatingEvery charge dated within duration_in_months of the first one it applies to.Reduced while it still applies.
foreverEvery 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.

shell
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.

shell
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}'
Response · 200 OK
{
"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.

reasonMeaning
coupon_not_foundNo coupon has this code.
coupon_inactiveThe coupon has been withdrawn with active: false.
coupon_expiredPast redeem_by.
coupon_price_mismatchprice_id is not in applies_to_price_ids.
coupon_below_minimumamount_cents is under min_amount_cents.
coupon_exhaustedmax_redemptions reached.
coupon_currency_mismatchThe 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.

shell
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

CaseResult
Metered pricesRefused at checkout. There is no fixed period fee to discount.
Plan changesA proration quote takes no coupon code.
One-off paymentsNo coupon field. Send the discounted amount.
RefundsA 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.