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

The four ways to bill a converting trial trade friction against SCA risk and failure visibility. Re-prompting is simple but bleeds conversion; the silent off-session charge is frictionless but pushes failure into an asynchronous webhook you must watch. The map places each on those axes.

Conversion approach trade-offs Re-prompt has high friction but immediate failure visibility; silent off-session has no friction but asynchronous failure; pre-auth and zero-dollar sit between. Re-prompt friction: high 30-40% drop fails on screen Silent off-session friction: none SCA risk: medium fails async Pre-authorize friction: none auth while present expiry windows Zero-dollar friction: none verify at start charge later
Silent off-session wins on friction but moves failure into an async webhook — so watch that path deliberately.

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

The pre-authorization row deserves more scrutiny than it usually gets, because its “no friction” claim is time-boxed. A card authorization holds for a finite window before the issuer releases it — typically 5 to 7 days on Visa and Mastercard, sometimes as little as 24 hours on certain debit BINs. A 14-day trial therefore cannot be covered by a single auth placed at trial start; the hold will have expired long before you capture. Teams that pick pre-auth for a two-week trial end up re-authorizing on a schedule, which reintroduces the same off-session mechanics this page describes plus the accounting overhead of tracking auth-to-capture linkage. Pre-auth only genuinely avoids the off-session charge when the trial is short — a 3-day or 5-day trial that fits inside the hold window. Above that, it is strictly more complex than a clean deferred capture with no compensating benefit.

The zero-dollar verification row has a subtler failure mode. A $0 or $1 account-verification transaction proves the card exists and the mandate is on file at trial start, but it proves nothing about the balance available at conversion. A prepaid or debit card that verifies fine on day 0 can be empty on day 14. Verification also does not establish an SCA exemption for the later real charge; you still run the merchant-initiated charge with the same authentication risk. Treat zero-dollar verification as a liveness probe for the token, not as a rehearsal of the conversion. It is worth doing when your decline analytics show a meaningful share of conversion failures come from cards that were never valid, and worth skipping when your population is overwhelmingly credit cards that stay live.

One number frames the entire decision: measure your own trial-to-paid rate under a re-prompt flow before assuming the 30–40% figure applies to you. A product where the trial has already delivered obvious value — the user has data in the system, invited teammates, wired an integration — loses far less to a re-prompt than a product where the trial is exploratory. The frictionless pattern earns its complexity when the delta between silent conversion and re-prompt conversion is large enough to pay for the webhook plumbing, the dunning path, and the SCA step-up flow. If that delta is a couple of points, the re-prompt row is the honest engineering choice.

Step-by-Step Implementation

The pattern front-loads all the friction to onboarding, when the customer is present and SCA is cheap, then does the conversion silently. The timeline shows the four steps spread across the trial: vault early, health-check throughout, charge at the deadline, and activate on the webhook.

Frictionless conversion timeline Vault the card with a SetupIntent at onboarding, health-check it during the trial, charge off-session at the deadline, then activate idempotently on the success webhook. 1 SetupIntent onboarding 2 Health-check daily, D-7 prompt 3 Off-session at deadline 4 Activate on webhook
Front-load friction to onboarding; the deadline charge and activation are silent and idempotent.

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.

The usage: 'off_session' flag on the SetupIntent is not cosmetic. It tells the gateway to collect a mandate — the cardholder’s stored agreement that you may charge them without their being present — and to record the transaction as the first in a merchant-initiated series. That recorded consent is precisely what your later charge references when it claims an exemption from a fresh SCA challenge. If you vault the card with an on-session SetupIntent and then try to charge off-session later, many issuers will reject the exemption because no valid mandate was established at collection time, and your “frictionless” conversion becomes a wall of authentication_required declines. The cheap authentication is only cheap because you did the SCA work once, live, at collection. Confirming the SetupIntent while the customer watches means any 3DS challenge resolves in the browser they already have open, rather than in an email link they may never click.

Persist a small amount of metadata alongside the payment_method_id at this step: the network, the last four digits, and the card’s expiry. You need the network and last-four to render “we’ll charge the card ending 4242” copy without a round-trip to the gateway, and you need the expiry locally so the health check in step 2 can run against your own database instead of hammering the gateway API for every trialing subscription every day.

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

Calendar expiry is only the most obvious way a stored method goes stale. Cards get reissued after fraud events, lost-and-replaced cards ship with new numbers, and the token you hold can point at a PAN that no longer exists even though its printed expiry is still in the future. This is what network account-updater services address: Visa Account Updater and Mastercard Automatic Billing Updater push new credentials to the gateway, which silently re-maps your payment_method_id to the new card. Enroll the vault in the updater so the token you charge at conversion follows the customer’s real card. The updater is not instant — updates propagate on the networks’ own cadence, often a few days — which is another reason the health check runs daily rather than only firing at the deadline.

The D-7 prompt exists because timing is everything for a card update. Ask on the day of conversion and a customer who needs to dig out a new card will miss the window and churn involuntarily; ask a week out and they have slack to act. Keep the prompt strictly non-blocking: it is a nudge, not a gate, and the trial keeps running whether or not they respond. Fire it exactly once per detected problem and track that you sent it, so a daily cron does not email the same customer seven days in a row and train them to ignore you.

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.

The choice of anchor for the key is the whole design. Two independent things can both decide it is time to convert a given subscription: a scheduled sweep that scans for trial_ends_at <= now() and a scheduler webhook the gateway fires when a trial timer elapses. If both run — and in a healthy system with retries and at-least-once delivery, both will occasionally run for the same subscription within the same second — you must not produce two charges. Because both derive the key from the same subscription_id and the same frozen trial_ends_at, both send an identical idempotency_key, and the gateway returns the first PaymentIntent to the second caller instead of creating another. The anchor must be immutable for this to hold: never fold now(), an attempt counter, or a mutable subscription status into the hash, or a legitimate retry after a transient network error will mint a fresh key and double-charge the customer.

Idempotency keys have a finite server-side lifetime — Stripe retains them for 24 hours, for instance. That window is generous for the near-simultaneous cron-versus-webhook race, but it is not a durable dedupe store. If your own retry logic could re-drive a conversion more than a day later, back the gateway key with a local uniqueness constraint: a conversion_attempts row keyed on (subscription_id, trial_ends_at) with a database unique index gives you exactly-once semantics that outlive the gateway’s key expiry and survive a full outage of your job runner.

Note the deliberate handling of a non-succeeded status. A PaymentIntent can return requires_action rather than throwing, and treating anything that is not succeeded as declined here is intentional — it collapses the ambiguous middle into the dunning path rather than leaving a subscription wedged in a half-converted state. The subsequent webhook is what ultimately confirms the money moved; this synchronous return value only decides which local branch to take right now.

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.

Making the webhook authoritative rather than the synchronous charge response is a decision worth stating plainly, because it inverts the naive flow. The tempting design activates the subscription the instant convertSilently returns 'active'. That works until the process crashes between the successful charge and the local write, and now the customer has paid but their account still shows a trial — the worst possible state to reconcile because it costs you goodwill and support time. Driving activation from payment_intent.succeeded instead means the money-moved fact and the account-state fact share a single trigger. If your handler crashes, the gateway redelivers the event, and the dedupe key ensures the redelivery is safe.

The Redis SET ... NX shown here is the dedupe primitive: it succeeds only if the key is absent, so the first delivery wins and every redelivery short-circuits. An 86400-second TTL matches the redelivery window most gateways use for a failed endpoint, and keeping the key that long means a burst of retries hours apart still collapses to one transition. Note that this is idempotency on the event, layered on top of the idempotency on the charge from step 3 — the two guards protect different boundaries. The charge key stops two charges; the event key stops one charge’s success from being applied twice. You want both, because a duplicate transition can double-count activation in your metrics or fire a welcome email twice even when no second dollar moved.

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

The three things worth proving: the conversion is exactly-once, an SCA challenge routes to step-up rather than a blind retry, and duplicate success webhooks are ignored. The panel is the test set.

Conversion verification tests Double-invoke yields one PaymentIntent, an authentication_required decline routes to step-up, and a duplicate success webhook is ignored. Exactly-once double invoke one PaymentIntent SCA path auth required routes to step-up Duplicate webhook second delivery ignored
Prove exactly-once, correct SCA routing, and webhook idempotency — the three ways silent conversion misbehaves.

Run this reconciliation continuously, not just in tests. A nightly job that flags any active subscription whose conversion window closed without a matching succeeded charge — and, symmetrically, any succeeded conversion charge whose subscription_id is still marked trialing — surfaces both crash-between-steps bugs and exemption failures before a customer files a ticket. The two directions catch opposite defects, so assert both.

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

The pitfalls here cluster around three assumptions that fail in production: that off-session charges are exemption-proof, that the vaulted card is still good, and that the cron job respects the same rules as the UI. The map names each.

Conversion pitfalls SCA can challenge off-session charges, cards expire mid-trial, opt-outs must be enforced in the guard, and the idempotency key must use the immutable anchor. SCA exemption fails → email step-up link Card expiry valid at signup → updater + D-7 Opt-out UI-only check → guard the flag Key source keyed on now() → use anchor
Four production assumptions that fail silently — and the one-line defense that catches each.
  • Off-session is not exemption-proof. PSD2 lets issuers challenge even merchant-initiated charges. If you treat authentication_required as 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, not converting. Check an explicit auto_convert flag in the guard, not in a UI layer that the cron job bypasses.
  • Never log the PAN or CVV. Reference payment_method_id only. Decline diagnostics should record the decline code and the intent id, never card data, to keep PCI scope minimal.

Frequently Asked Questions

When should the conversion charge be attempted? At the trial end instant, with a reminder several days before. Charging without a reminder is legal in most places and is the single largest driver of trial-related disputes.

What happens if the conversion charge fails? It enters the normal retry ladder, but the messaging should differ: this customer has never successfully paid, so the tone is “let’s get you set up” rather than “your payment failed”.

Should access continue while the first charge is retrying? Usually yes, briefly. Cutting access at the first failure loses customers whose card simply needed updating, and the exposure for a few days of service is small.

Does the reminder hurt conversion? It reduces immediate conversion slightly and reduces disputes and refunds substantially, which nets positive for almost every product — and it is increasingly a regulatory expectation.