Selling a free trial, once per buyer
Blog GuideFree trials are sellable again in 2.2.0, and they now come with a limit: one trial per buyer per Application. This is what you set, what your buyer sees, what refuses you, and how to read the new eligibility endpoint so your pricing page tells the truth.
Setting a trial up
The trial lives on the plan, in one field: trialDays. A whole number of days, and the two routes accept different ranges on purpose: 1 to 365 when you create a plan, 0 to 365 when you patch one, where 0 is how you withdraw an offer you already advertised. Leave the field out on create and the plan has no trial; leave it out on patch and the stored value is untouched. Send null and create refuses it, while patch coerces it to 0 and clears the trial, so send the 0 you mean rather than relying on that.
Two conditions apply before a trial can reach a buyer. trialDays is legal on SUBSCRIPTION plans only, because a one-off charge has nothing for a trial to convert into: a CREDIT or LICENSE plan is refused with PLAN_TRIAL_NOT_APPLICABLE. And the provider has to be able to run the trial clock itself. Stripe declares that capability; PayPal and Razorpay do not, so a checkout routed to either is refused with BILLING_TRIAL_UNSUPPORTED rather than charging a buyer who was shown a free trial.
Set it when you create the plan, or patch it onto a plan you already sell. Both routes are on the operator API, with an operator session or PAT as the bearer token and billing-write access to the Application.
# a new plan with a 14 day trial
curl -X POST https://api.rekey.dev/api/v1/tenant/applications/app_123/plans \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug":"pro","name":"Pro","amount":2900,"currency":"USD",
"interval":"MONTH","kind":"SUBSCRIPTION","trialDays":14}'
# add one to a plan already on sale, or send 0 to withdraw the offer
curl -X PATCH https://api.rekey.dev/api/v1/tenant/applications/app_123/plans/pro \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"trialDays":14}'trialDays stays editable after the plan is registered with the provider, unlike price. The trial is applied per checkout session rather than baked into an immutable price object, so changing it changes what the next buyer gets and touches nobody who already started one.
Panel
What the buyer gets at checkout
Nothing changes in your checkout call. Your app posts to POST /api/v1/billing/checkout with the plan slug and gets a provider URL back. Rekey resolves the plan's trial, reserves the buyer a trial slot before the provider call, and sends the trial days to the processor, so the provider page shows the trial and the date of the first charge. The local Subscription row is PENDING until the provider's webhook arrives.
When it does, the row is written TRIALING with a trialEndsAt, and it entitles: feature flags, quotas and everything else resolve during the trial exactly as they would for a paying subscriber. When the provider converts the trial into a charge, the row becomes ACTIVE.
One trial per buyer
Until 2.2.0, the trial was a pure function of the plan. Nothing recorded that a buyer had trialled before, so checkout granted one every time: trial, cancel on the last day, trial again, without limit. On a plan that granted credits, each loop handed out a fresh period of them.
Now checkout takes a trial slot under a lock, and a repeat trialist is refused with 409 BILLING_TRIAL_ALREADY_USED. The refusal names the plan already trialled and the price that would be charged instead.
{
"success": false,
"error": {
"code": "BILLING_TRIAL_ALREADY_USED",
"message": "This account has already used a free trial of \"basic\" and is not eligible for another, so subscribing to \"pro\" would charge $29.00 today.",
"fix": "Read GET /api/v1/billing/trial-eligibility before offering a trial, and present the paid price to a buyer who is not eligible. To continue at full price, retry with `allowWithoutTrial: true` AND a new Idempotency-Key, once the buyer has been told they will be charged today.",
"requestId": "req_..."
}
}The slot is counted against whatever the Application bills. A user-billed Application counts per end-user; an org-billed one counts against the beneficiary organization, because counting per person would hand a five-person team five trials and would refuse a colleague who takes the account over.
Selling to that buyer anyway
The refusal is not a dead end, it is a handshake. Show the buyer the paid price, and when they accept it, retry the checkout with allowWithoutTrial: true. That flag is an acknowledgement from the call site that rendered the price: the checkout goes through at full price, no slot is taken, and the provider is told to start charging rather than to run a clock Rekey already refused.
If your first attempt carried an Idempotency-Key, the retry needs a new one. The same key with a different body is refused with 409 IDEMPOTENCY_KEY_REUSED, and reusing it unchanged would replay the stored refusal.
curl -X POST https://api.rekey.dev/api/v1/billing/checkout \
-H "Authorization: Bearer $PUBLISHABLE_OR_SECRET_KEY" \
-H "X-Rekey-User-Token: $USER_ACCESS_TOKEN" \
-H "Idempotency-Key: $A_FRESH_UUID" \
-H "Content-Type: application/json" \
-d '{"planSlug":"pro",
"successUrl":"https://yourapp.com/billing?status=ok",
"cancelUrl":"https://yourapp.com/billing?status=cancel",
"allowWithoutTrial":true}'Send it only after the buyer has been told. A buyer who merely abandoned a trial checkout is still eligible, and acknowledging on their behalf charges them today for the trial the next checkout was about to grant.
To keep the old behaviour instead, change the rule for the whole Application. billingConfig.trialPolicy takes once_per_application (the default), once_per_plan, or unlimited, which never refuses anyone.
curl -X PATCH https://api.rekey.dev/api/v1/tenant/applications/app_123/billing-config \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"trialPolicy":"unlimited"}'The default counts across every plan on purpose. Trialling basic and then pro is two free months of your product, and once_per_plan allows it. Redemptions are still recorded under unlimited, so flipping back to a real policy later means something rather than starting everyone from zero.
Asking before you offer
A pricing page that renders "start your free trial" from the plan alone is guessing. GET /api/v1/billing/trial-eligibility answers the per-buyer half. It takes a publishable or secret key plus the signed-in user's token, and returns one row per active plan, with the Application's policy and the provider the answers were resolved against.
{
"success": true,
"data": {
"items": [
{ "planSlug": "pro", "trialDays": 14, "eligible": true,
"reason": null, "redeemedAt": null, "endsAt": null },
{ "planSlug": "basic", "trialDays": 14, "eligible": false,
"reason": "ALREADY_REDEEMED",
"redeemedAt": "2026-07-02T09:14:00.000Z", "endsAt": null }
],
"page": { "total": 2, "limit": 50, "offset": 0, "hasMore": false },
"policy": "once_per_application",
"provider": "stripe"
}
}reason is evaluated in a fixed order, first match wins:
PLAN_HAS_NO_TRIAL, the plan offers none.PLAN_TRIAL_MISCONFIGURED, the plan carries a trial this checkout could never honour, a one-off purchase or a provider that cannot run trials. The plan is unbuyable, not merely trial-less, and checkout answers 400. Hide it and fix the plan.- Under
unlimitedthe walk stops here and every remaining plan answers eligible, so the two reasons below never appear on that policy. TRIAL_IN_PROGRESS, a trial is running on this plan, withendsAt.ALREADY_REDEEMED, an earlier trial spends the slot under this policy, withredeemedAt.
Both SDKs expose it as getTrialEligibility, so the label on your button is one call away.
const { items, policy } = await rekey.billing.getTrialEligibility(accessToken);
const pro = items.find((i) => i.planSlug === "pro");
const label = pro?.eligible
? `Start ${pro.trialDays} days free`
: "Subscribe";The answer is advisory. The authoritative decision is taken under a lock at checkout, so two tabs can both read eligible: true and only one of them gets the trial. Treat a 409 at the button as normal rather than as a contradiction. The answer is also provider-dependent, which is why the response echoes provider: re-read it when the buyer picks a different processor.
In React, feed items straight into PricingTable through its trialEligibility prop. It then offers a trial only to a buyer the API says may have one, never because a plan carries trial days.
A trial on a credit pack or a licence is refused
Put trialDays on a plan that grants a CREDIT or LICENSE entitlement and the write is refused with 400 PLAN_TRIAL_MATERIALISES_ENTITLEMENTS. Two paths carry the guard: patching a plan, and adding a CREDIT or LICENSE entitlement to a plan that already has a trial. Creating a plan in one call does not, because a new plan has no entitlements yet, so the combination can only appear on the second write.
{
"success": false,
"error": {
"code": "PLAN_TRIAL_MATERIALISES_ENTITLEMENTS",
"message": "Plan \"pro\" offers a free trial and grants credits, which would be handed over on day 0, before the first payment, with no way to take them back.",
"fix": "Sell the trial on a plan whose entitlements are FEATURE or USAGE only, those resolve at read time and lapse with the subscription. To sell credits or a licence, drop `trialDays` and charge for them, or split them onto a separate plan.",
"requestId": "req_..."
}
}The reasoning is short. Entitlements are provisioned when the subscription activates, and for a trial that is day 0. A CREDIT entitlement mints credits and a LICENSE entitlement issues a key, both before any money moves, and neither has an inverse: the buyer cancels before the first invoice and keeps them. FEATURE and USAGE entitlements resolve at read time and stop resolving the moment the subscription stops entitling, so a lapsed trial takes them with it. That is the ordinary feature-gated trial trialDays exists for. The per-buyer limit bounds the other case to once, which is not the same as fixing it.
Trialists do not count as revenue
A subscription in its trial is stored TRIALING, not ACTIVE. Both entitle, so what the buyer can do is identical. What changes is the books: reported MRR sums plan amounts over ACTIVE subscriptions only, so a 30 day trial on a $99 plan no longer adds $99 on day 0 against zero cash. The row turns ACTIVE when the provider converts the trial into a charge, which is when its revenue starts counting.
Before you switch it on
trialDays from before 2.1.0, those buyers have no redemption history, so the first trial each of them takes after the upgrade is the one the new limit records. Decide whether that is what you want before you flip trialPolicy away from the default.Where next
The billing guide covers plans, entitlements and checkout end to end, and the interactive API reference has both routes used here, callable from the browser. If you are wiring a pricing page from scratch, the Next.js starter already has one fed by real plans, and adding the eligibility call to it is a few lines.
