Free trials
Configure a trial on the price, override it per checkout, and handle the events around conversion.
A card is always taken
BillKit has no card-later trial. Every subscription needs a mandate before it can renew, a mandate only comes from a real payment, so a trial starts with a small verification charge rather than nothing.
The amount is trial_verification_cents on the price, 100 cents by default. On a tax-exclusive
price VAT is added on top of it; otherwise it is the gross amount. It sells nothing, so it gets no
invoice. It is still the charge that started the subscription, so refunding it in full through
POST /v1/refunds ends the trial on the spot, exactly as a full refund of a paid first period
ends that subscription: status becomes canceled with cancellation_reason: "refund_issued",
subscription.canceled fires, and nothing is charged at trial_end. A partial refund leaves the
trial running. The default clears the minimum first charge of every payment method a price can
offer, including EPS at €1.00.
That charge is not refunded automatically. Nothing in BillKit reverses it. Either keep it small enough to be unremarkable, or say what it is on the pricing page. Refunding it in full yourself ends the trial, so do that only for a customer who is leaving. Do not write copy promising it will come back.
If a €1 charge is unacceptable in your market, do not use a trial. Sell a cheap first period, or gate the product behind a free tier that has no subscription at all.
Configure it on the price
curl https://api.billkit.eu/v1/prices \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_id": "prod_kQ2v8Zr4nT1yLpXaBc",
"amount_cents": 2900,
"currency": "EUR",
"interval": "month",
"trial_days": 14,
"trial_verification_cents": 100,
"payment_methods": ["creditcard", "ideal"]
}'trial_days is between 0 and 90. trial_verification_cents can be lower, down to 0. BillKit then
raises the charge to the smallest amount the price’s payment methods accept: zero on card only,
one cent with iDEAL or PayPal, two with Apple Pay, €1.00 with EPS.
Everything that decides money on a price is fixed at creation, trial_days included.
POST /v1/prices/{id} only accepts the forward-looking fields: active, payment_methods,
metadata, the refund settings, and tax_behavior while it is still unspecified. Nothing that
would restate a charge somebody has already paid is on that list.
So changing a trial length means creating a new price and pointing new checkouts at it. Existing subscriptions keep the terms they started on, which is usually what you want anyway.
Override per checkout
Useful for a sales-negotiated trial, or for a campaign that runs longer than the default.
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",
"trial_days_override": 28,
"success_url": "https://example.com/welcome",
"cancel_url": "https://example.com/pricing"
}'The override is capped server-side at twice the price’s trial_days, with a floor of 14 days on
that basis. So a price with trial_days: 14 allows up to 28, a price with trial_days: 30
allows up to 60, and a price with no trial at all still allows up to 28. Exceeding the cap is a
400 naming the limit. The absolute ceiling is 180 days.
trial_days_override: 0 disables a trial the price would otherwise grant, which is how you sell
the same plan without one.
Metered prices reject trials entirely, at both levels.
The lifecycle
| Moment | What happens |
|---|---|
| Checkout settles | Status is trialing. trial_start is now, trial_end is now plus the trial length, and current_period_end equals trial_end. subscription.created and subscription.trial_started fire. |
| 3 days before the end | subscription.trial_will_end fires and BillKit emails the customer: plan, charge date, amount, and the saved payment method. Once only, per subscription. |
| trial_end | BillKit charges the first period against the mandate: the price, less any coupon, with VAT added on a tax-exclusive price. On success the subscription becomes active and subscription.updated, subscription.trial_ended and payment.succeeded fire. |
| If the charge fails | Status becomes past_due, subscription.past_due and subscription.trial_ended fire, and dunning starts, with the same retries as any renewal. See Recover failed payments. |
| 24 hours after trial_end, still trialing | If the first charge has not settled by then, subscription.trial_ended fires anyway with status still trialing. It does not fire again: when the charge lands, the subscription becomes active and only subscription.updated (with previous_attributes.status of trialing) and payment.succeeded fire. |
| If the customer cancelled or paused during the trial | No charge is scheduled, so the three-day email is not sent, although subscription.trial_will_end still fires. The trial ends the subscription the way a cancelled period does. Once trial_end has passed, the subscription becomes canceled with cancellation_reason: "period_end" and canceled_at set to trial_end, and subscription.canceled fires. serves_customer is already false from trial_end. |
The first charge is BillKit’s first renewal of the subscription: it is taken at trial_end, and
a failure is retried like any other renewal.
The three-day warning is an event as well as an email. subscription.trial_will_end fires from the
same pass that sends the mail, and it fires whether or not the mail could be sent, so an in-app nudge of your
own can hang off it rather than off a scheduler you maintain.
Handling conversion
export async function handleEvent(event) {
switch (event.type) {
case "subscription.trial_started":
// trial_end is Unix seconds.
await startTrial(event.data.customer_id, {
subscriptionId: event.data.id,
endsAt: new Date(event.data.trial_end * 1000),
});
break;
case "subscription.trial_will_end":
// Three days out. Your own in-app nudge goes here.
await nudgeBeforeCharge(event.data.customer_id, event.data.id);
break;
case "subscription.updated":
// Conversion is the move from "trialing" to "active". Read it here
// rather than off subscription.trial_ended, which fires once and,
// when the first charge is slow to settle, before it does.
if (
event.previous_attributes?.status === "trialing" &&
event.data.status === "active"
) {
await markConverted(event.data.customer_id);
}
break;
case "subscription.canceled":
await revokeAccess(event.data.customer_id);
break;
}
}Gate entitlement on serves_customer, exactly as you would without a trial. Do not write separate
trial logic; trialing is a fully entitled state and the boolean already says so. See
Two state fields, not one for why reading status
by hand is the thing to avoid.
You cannot extend a running trial
There is no endpoint for it, and no field on any update route that accepts one. The trial length is fixed the moment the checkout session is created.
If you need a customer to have longer, the workable answer is to cancel the subscription, then
send them a new checkout session with a larger trial_days_override. That produces a second
verification charge, so it is worth making the first trial the right length rather than planning
to extend.
Testing it
Attach the customer to a test clock before checkout, then advance the clock
more than 24 hours past trial_end. The first charge and the trial expiry both run during the
advance, charge first, so their events land before the call returns. The charge itself is a Mollie
test payment, and Mollie decides when it settles, not the clock. If it is already paid, the
subscription is active and subscription.trial_ended fires with it. If it is still open,
subscription.trial_ended fires with status: "trialing", and the subscription becomes active
when the payment is paid. A customer who cancelled during the trial gets subscription.canceled
instead.
The trial deadlines are set from real time when the checkout settles, so create the clock at or
near the current time. Without a clock, the same steps run on real time: the charge at trial_end,
the expiry 24 hours later.
BillKit