Free Trial Conversions Without Payment Friction
The friction that kills trial conversion is almost never price โ it is the checkout form you make the customer fill out at the moment of conversion. Asking for a card at expiry, when attention has moved on, routinely drops conversion by 30โ40%. This page sits under Trial Period Management and shows the backend pattern that removes that step entirely: collect the payment method early with a SetupIntent, then convert silently with an off-session charge keyed for exactly-once execution. The engineer reaches for this when a card is collected up front (or mid-trial) and the only remaining job is to bill it without a second interaction.
Trade-offs
โFrictionlessโ is a backend posture, and it has costs. The table below contrasts the silent off-session approach against the alternatives so the choice is explicit.
| Approach | Conversion friction | SCA/3DS risk | Failure visibility | Implementation cost |
|---|---|---|---|---|
| Re-prompt for card at expiry | High โ 30โ40% drop-off | Low (customer present) | Immediate, on screen | Low |
| Silent off-session charge (this page) | None | Medium โ may need step-up if exemption fails | Asynchronous, via webhook | Medium |
| Pre-authorize at trial start, capture later | None | Low (auth done while present) | At capture time | Medium-high (auth expiry windows) |
| Charge $0 verification at start, charge later | None | Low | At real charge | Medium |
The off-session charge is the right default when you already vaulted a card during onboarding. Its one real risk is SCA: if the issuer demands authentication and your Merchant-Initiated-Transaction exemption is rejected, the charge soft-fails and you must surface a step-up. Plan for that path rather than assuming exemptions always hold.
Step-by-Step Implementation
1. Attach the payment method early with a SetupIntent
Collect and vault the card during onboarding, when the customer is present and authentication is cheap. Store only the payment_method_id โ never raw PAN.
// During onboarding, while the customer is present (SCA handled live)
const setupIntent = await stripe.setupIntents.create({
customer: customer_id,
usage: 'off_session', // โ
mandate for later merchant-initiated charges
payment_method_types: ['card'],
});
// Client confirms it; you persist the resulting payment_method_id
2. Health-check the stored method during the trial
A card vaulted on day 0 can expire by day 14. Run a daily check and prompt the customer to update well before the deadline, so conversion does not fail silently.
async function checkPaymentHealth(sub: TrialSub): Promise<void> {
const pm = await stripe.paymentMethods.retrieve(sub.paymentMethodId);
const expired = pm.card!.exp_year < currentYear ||
(pm.card!.exp_year === currentYear && pm.card!.exp_month < currentMonth);
if (expired) {
await notifyUpdateCard(sub.customerId); // โ ๏ธ non-blocking, 7 days before trial end
}
}
3. Convert silently with an idempotent off-session charge
At the deadline, charge off-session. The idempotency key is derived from the subscription id and the immutable trial-end instant, so a cron trigger and a webhook trigger collapse to one charge.
import crypto from 'crypto';
async function convertSilently(sub: TrialSub): Promise<'active' | 'declined'> {
const idempotencyKey = crypto
.createHash('sha256')
.update(`${sub.id}:${sub.trialEndsAt}`) // immutable anchor
.digest('hex');
try {
const intent = await stripe.paymentIntents.create(
{
amount: sub.amountCents, // integer minor units, never float
currency: sub.currency,
customer: sub.customerId,
payment_method: sub.paymentMethodId,
off_session: true,
confirm: true,
},
{ idempotencyKey }
);
return intent.status === 'succeeded' ? 'active' : 'declined';
} catch (err) {
const e = err as Stripe.errors.StripeCardError;
// โ may carry authentication_required โ route to step-up, not silent retry
if (e.code === 'authentication_required') await requestStepUp(sub);
return 'declined';
}
}
4. Activate idempotently on the success webhook
The webhook handler is the source of truth. Verify the signature, dedupe on the event id, then transition the subscription to active โ the same guarded transition described in subscription lifecycle states.
async function onPaymentSucceeded(event: Stripe.Event): Promise<void> {
const isNew = await redis.set(`conv:${event.id}`, '1', 'EX', 86400, 'NX');
if (!isNew) return; // โ ๏ธ duplicate delivery โ ignore
const intent = event.data.object as Stripe.PaymentIntent;
await applyEvent(intent.metadata.subscription_id, 'trial_converted', {});
}
Verification & Testing
Assert exactly-once conversion by invoking the convert path twice with the same (subscription_id, trial_ends_at) and confirming the gateway records one PaymentIntent and your FSM performs one transition. Use the gatewayโs test cards to force an authentication_required decline and assert your code routes to step-up rather than retrying blindly. Simulate a duplicate payment_intent.succeeded delivery and assert the second is ignored. A reconciliation query that joins converted subscriptions to gateway charges and flags any active subscription with no successful charge in the conversion window catches silent activation bugs in production.
Gotchas & Production Pitfalls
- Off-session is not exemption-proof. PSD2 lets issuers challenge even merchant-initiated charges. If you treat
authentication_requiredas a generic decline you silently lose convertible customers; handle it as a distinct path that emails a one-click authentication link. - The idempotency key must use the immutable anchor. Keying on โnowโ or a mutable status lets a retry produce a second key and a second charge. Bind it to
trial_ends_at, which never changes for a given trial. - Card expiry is the top silent failure. A card valid at signup expires by conversion more often than you expect. Run account-updater refreshes and a D-7 prompt; do not discover the dead card at the charge.
- Respect opt-outs in the state machine. A customer who chose not to auto-convert must route to
expired, notconverting. Check an explicitauto_convertflag in the guard, not in a UI layer that the cron job bypasses. - Never log the PAN or CVV. Reference
payment_method_idonly. Decline diagnostics should record the decline code and the intent id, never card data, to keep PCI scope minimal.