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