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, plus VAT. It exists
because Mollie will not reliably create a card mandate from a zero-value charge.
That charge is not refunded automatically. Nothing in BillKit reverses it. Either keep it small enough to be unremarkable, refund it yourself if you want to, or say what it is on the pricing page. Do not write copy promising it will come back.
If the €1 hold is unacceptable for your market, the honest alternative is not 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 0, though Mollie may then
refuse to create the mandate for some card products.
Everything that decides money on a price is fixed at creation, trial_days included. The only
field POST /v1/prices/{id} accepts is active, which takes the price off sale or puts it back.
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 | BillKit emails the customer: plan, charge date, amount, and the saved payment method. Once only. |
| trial_end | Mollie charges the full price against the mandate. On success the subscription becomes active and subscription.trial_ended, subscription.updated and payment.succeeded fire. |
| If the charge fails | Status becomes past_due and dunning starts. See Recover failed payments. |
| If the customer cancelled during the trial | The subscription becomes canceled at trial_end with cancellation_reason: "trial_expired_unpaid". |
The renewal is scheduled at Mollie with a start date of trial_end, so nothing has to fire on
time in BillKit for the conversion to happen.
There is no subscription.trial_will_end event. The three-day warning is an email only. If you want
your own in-app nudge, read trial_end off the subscription and schedule it yourself.
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_ended":
// The trial is over. Check status: converted, or not.
if (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
past trial_end. The trial reaper runs during the advance, so the conversion events land before
the call returns.
What a clock cannot do is make Mollie charge. The renewal is on Mollie’s real-time schedule, so a clock-driven test exercises BillKit’s trial expiry, not the money.
BillKit