Trial Period Management
A trial is the riskiest part of the subscription lifecycle to get wrong because nothing is charged yet, so bugs are silent: entitlements that never expire, conversions that fire twice, or reminders that arrive after the customer is already billed. This page is part of Subscription Billing Architecture & Pricing Models and treats trial management as a small, deterministic slice of the broader subscription lifecycle states: a clock-driven deadline, an idempotent conversion charge, and a reminder schedule, all anchored to a single immutable UTC timestamp. Whether you collect a card up front is a product decision with engineering consequences β covered in depth in Card Required Vs No Card Trials Conversion Tradeoffs.
Prerequisites
Architecture & Data Flow
The trial subsystem has one input that matters β the passage of time toward trial_ends_at β plus optional customer actions (cancel, add card). A scheduler scans for upcoming and past deadlines, emits reminder and conversion events, and the FSM transitions the subscription. Entitlement and email side-effects flow through the outbox so they never fire without the state change committing.
Implementation Walkthrough
1. Start the trial and anchor the deadline
Compute trial_ends_at once, in UTC, and never recompute it from calendar days later. Everything downstream references this single value.
from datetime import timedelta
from django.utils import timezone
def start_trial(customer_id: str, price_id: str, trial_days: int) -> str:
now = timezone.now() # tz-aware UTC
sub = Subscription.objects.create(
customer_id=customer_id,
price_id=price_id,
current_state="trialing",
trial_ends_at=now + timedelta(days=trial_days), # immutable anchor
)
Outbox.objects.create(
aggregate_id=sub.id,
event_type="entitlement.granted",
payload={"customer_id": customer_id, "price_id": price_id},
)
return sub.id
2. Scan deadlines on a clock, not on webhooks
A periodic job finds trials that are nearing or past their deadline. Using the clock as the trigger means a missing trial_will_end webhook never strands a subscription in trialing forever.
-- Trials due for conversion now (claimed in batches by parallel workers)
SELECT id, customer_id, price_id, trial_ends_at
FROM subscriptions
WHERE current_state = 'trialing'
AND trial_ends_at <= now()
FOR UPDATE SKIP LOCKED
LIMIT 200;
3. Schedule reminders before the deadline
Reminder timing is a fixed offset from the immutable deadline. Emit them through the outbox so a reminder is never sent for a trial that was already canceled in the same transaction.
REMINDER_OFFSETS_DAYS = [7, 3, 1] # D-7, D-3, D-1 before trial_ends_at
def schedule_reminders(sub) -> None:
for days_before in REMINDER_OFFSETS_DAYS:
send_at = sub.trial_ends_at - timedelta(days=days_before)
if send_at <= timezone.now():
continue # β οΈ skip reminders already in the past for short trials
ReminderJob.objects.create(
subscription_id=sub.id, send_at=send_at, kind=f"trial_d{days_before}"
)
4. Run the conversion charge idempotently
The charge that converts the trial must be exactly-once. Derive the idempotency key from the subscription id and the immutable trial-end instant so retries and concurrent triggers collapse to one charge. The detailed friction-free flow lives in Handling Free Trial Conversions Without Payment Friction.
async function convertTrial(sub: TrialSub): Promise<'active' | 'expired'> {
if (!sub.paymentMethodId) return 'expired'; // β no-card trial with no card added
const idempotencyKey = `convert:${sub.id}:${sub.trialEndsAt}`; // immutable anchor
try {
await psp.charges.create(
{
amount: sub.amountCents, // integer minor units
currency: sub.currency,
payment_method: sub.paymentMethodId,
off_session: true,
confirm: true,
},
{ idempotencyKey }
);
return 'active'; // β
converted
} catch (e) {
await enqueueDunning(sub.id); // β οΈ retry path before giving up
return 'expired';
}
}
Edge Cases & Failure Modes
| Scenario | Why it breaks | Mitigation |
|---|---|---|
trial_ends_at recomputed from βnow + N daysβ on edit |
Customerβs deadline silently shifts; reminders misfire | Treat the anchor as immutable; only an explicit extension event may change it |
| Conversion charge fires twice (cron + webhook) | Double-charge | Idempotency key derived from (subscription_id, trial_ends_at) |
| Payment method expires mid-trial | Silent conversion failure at the deadline | Account-updater refresh + a D-7 prompt to update the card |
| Reminder scheduled in the past for a 2-day trial | Email never sends or sends late | Skip offsets that fall before now(); clamp to the start |
| Trial-expiry webhook lost | Row stuck in trialing, entitlements leak |
Clock-driven scan converts on deadline regardless of webhooks |
| Same email starts many trials | Trial abuse, free usage forever | Fingerprint + email-normalization checks at trial start |
Performance & Scale
The conversion scan is the only query that touches every trialing row, so index subscriptions(current_state, trial_ends_at) and let workers claim batches with FOR UPDATE SKIP LOCKED to parallelize without lock contention. Reminders fan out from a reminder_jobs table indexed on (send_at) partial to undelivered rows; a single worker draining due jobs handles tens of thousands of trials per minute. Keep the conversion charge off the request path entirely β it is a background job β so a slow gateway never blocks a user. At high volume, batch entitlement-grant outbox rows by customer to reduce downstream write amplification.
Testing Strategy
Inject a mock clock so trial-start, reminder, and conversion tests are deterministic rather than dependent on wall time. Run the conversion job twice for the same subscription and assert exactly one charge and one state transition. Test the short-trial path where some reminder offsets fall in the past and assert they are skipped, not sent late. Simulate a lost expiry webhook by never delivering it and assert the clock-driven scan still converts the trial on its deadline. For anti-abuse, assert that a normalized duplicate email is flagged before a second trial provisions.
Frequently Asked Questions
Should the conversion charge run synchronously when the trial ends? No. Run it as an idempotent background job keyed on the trial-end instant. Synchronous charging on the request path couples user latency to gateway latency and makes retries awkward; a background job converts cleanly and lets dunning take over on decline without blocking anyone.
How do I prevent a customer from getting unlimited free trials? Normalize and fingerprint at trial start β lowercase and strip plus-addressing from emails, hash the payment instrument if one is collected, and rate-limit trials per fingerprint. The full anti-abuse trade-off, including when card-up-front is the right lever, is in Card Required Vs No Card Trials Conversion Tradeoffs.
Do trial periods need ledger entries even though nothing is charged?
Record them as zero-amount, non-revenue line items with a distinct GL code so acquisition cost is traceable and tax engines log the 0.00 transaction, but never as recognized revenue. This keeps ASC 606 / IFRS 15 reporting clean and separates trial cohorts from paid ones.
What is the right reminder schedule? A D-7 / D-3 / D-1 cadence covers most SaaS: early enough to update a failing card, late enough to catch intent. For short trials (under 7 days), collapse to D-3 / D-1 and skip any offset that lands before the trial started.