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
Trial management is deceptively simple until you notice how much of it hinges on time being handled correctly. Every prerequisite below exists to make the trial deadline a single, immutable, clock-driven fact rather than something recomputed on each request. The stack shows what must be in place before the first trial starts.
The TIMESTAMPTZ requirement is not a stylistic preference. If you store trial_ends_at as a naive TIMESTAMP (no zone) or, worse, as a DATE, you inherit two bugs that only surface in production. A DATE column silently discards the time-of-day component, so a trial that started at 14:00 UTC on the 1st and should end at 14:00 UTC on the 15th instead expires at 00:00 UTC — fourteen hours early for every customer, and the reminder offsets shift with it. A naive TIMESTAMP is worse because it appears to work: the value round-trips fine on a server pinned to UTC, then a colleague runs a backfill from a laptop set to America/New_York and every anchor written that day lands five hours off. Store UTC, compare in UTC, and only ever localize at the presentation edge when you render a reminder email.
The scheduler being independent of gateway webhooks is the single most important architectural constraint on this page, so it earns a prerequisite of its own. Payment service providers deliver webhooks like customer.subscription.trial_will_end on a best-effort basis; they retry, they occasionally drop, and during an incident they can lag by hours. If the only thing that moves a subscription out of trialing is a webhook, then a dropped event leaves a customer entitled to a paid product indefinitely and unbilled — a silent revenue leak that no error rate will surface because nothing threw. The clock-driven scan is the ground truth; webhooks are an optimization that lets you react a few seconds faster when they do arrive.
The idempotency-key store deserves scrutiny before the first trial converts. It must persist the key with the same durability as the charge itself and outlive the request that created it, because the whole point is that a retry hours later still finds the record. A key held only in application memory, or in a cache with a short TTL, defeats the mechanism precisely when you need it: the cron job runs, the process is recycled, the webhook arrives an hour later, the cache has evicted the key, and you charge customer_id twice. Persist keys in the same database as the subscription, in the same transaction as the state transition where possible.
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.
The data flow is deliberately one-directional. Time advances, the scheduler observes that advance, and the finite state machine reacts — nothing in the trial subsystem mutates the anchor as a side effect of reacting. This matters because it makes the whole thing replayable. If you need to reconstruct why subscription_id sub_9f21 converted at exactly the instant it did, you read trial_ends_at, apply the fixed reminder offsets, and the entire schedule falls out deterministically. Contrast this with a design where each reminder job reschedules the next one relative to when it happened to run: a single delayed worker drifts the whole chain, and there is no authoritative record of what the schedule was supposed to be.
The optional customer actions — cancel and add-card — enter the FSM through the same event path as the clock ticks, which keeps the number of transition rules small. A cancel during trialing moves the row to a terminal expired state and, crucially, revokes the entitlement through the outbox rather than inline; if you revoke inline and the transaction later rolls back, you have taken away a paying-intent customer’s access while leaving them in trialing. An add-card action during the trial does not touch the anchor at all — it only populates payment_method_id — so a customer who adds a card on day 3 of a 14-day trial still converts on day 14, not on day 3. Engineers new to the domain frequently get this wrong and charge the moment a card is vaulted, which turns a trial into a paid signup and generates support tickets.
State transitions and the trial_ending signal
The intermediate trial_ending state exists for a reason that only becomes obvious at scale: it decouples “we have decided this trial is in its final stretch” from “we are attempting the charge.” When the scan finds a trial whose deadline is within the next reminder window, it can flip the row to trial_ending and let downstream systems — in-app banners, sales-assist alerts for high-value accounts, the reminder drain — key off that single state rather than each recomputing the offset independently. The converting state then represents the narrow window where a charge is in flight, which lets a concurrent worker see that the row is already claimed and skip it. Without a distinct converting state you lean entirely on row locks, which works, but the explicit state gives you an observable signal: a spike in rows stuck in converting is a stalled gateway, visible on a dashboard, where a spike in held locks is not.
Implementation Walkthrough
The four steps anchor the deadline, drive expiry from a clock, schedule reminders off the anchor, and convert idempotently. The through-line is the immutable trial_ends_at instant — every other step reads it and none recompute it.
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
Notice that the entitlement grant is written to the outbox in the same transaction that creates the subscription, not called out to a provisioning service inline. If you provision inline — flip a feature flag, call the licensing API — and the transaction then fails on a constraint or a deadlock retry, you have granted access to a subscription that does not exist in your own database. The outbox pattern guarantees that the grant and the row commit atomically or not at all; a separate relay reads the outbox and performs the actual provisioning with its own retry semantics. The same discipline applies to the trial_days argument: validate it at the boundary. A negative or zero value should be rejected outright rather than producing a trial_ends_at in the past, which would make the very first scan convert the trial before the customer has seen the product.
One subtlety worth stating explicitly: timezone.now() is captured once into now and reused for both the record fields and the anchor arithmetic. Calling now() twice within the function — once for a created_at and again for the anchor — introduces a microsecond-to-millisecond skew that is harmless here but becomes a real bug in code paths that compare the two values for equality. Capture the instant once per logical operation and thread it through.
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;
The FOR UPDATE SKIP LOCKED clause is what makes this scan safe to run from many workers at once. Each worker claims up to 200 rows, locks them for the duration of its transaction, and any concurrent worker skips the locked rows rather than blocking behind them. This is the difference between a scan that scales horizontally and one that serializes every worker behind a single lock queue. The LIMIT 200 is a deliberate batch ceiling: it bounds how long any one transaction holds locks, which keeps the row-lock hold time short and lets a crashed worker’s rows become claimable again quickly when its transaction aborts. Tune the batch size to your charge throughput — if a single conversion charge takes 400ms against the gateway, 200 rows per worker is twenty-plus seconds of held locks, and you likely want a smaller batch with more workers.
Run the scan on a cadence that is short relative to your reminder granularity but not so short that batches overlap. A one-minute scan interval is a reasonable default: it means a trial converts within a minute of its true deadline, which is imperceptible to a customer and precise enough for revenue recognition. Guard against overlap — if a scan run takes longer than the interval because the gateway is slow, the next tick should detect the in-flight run and defer rather than pile a second wave of workers onto the same backlog. A simple advisory lock per scan job, or a converting-state check as described above, prevents the thundering-herd behavior where a gateway slowdown triggers ever more concurrent workers.
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}"
)
The kind field doubles as a natural deduplication key. If your reminder table carries a unique constraint on (subscription_id, kind), then re-running schedule_reminders for the same subscription — which happens whenever a job is retried after a partial failure — cannot enqueue a second trial_d7 email. Without that constraint, a retry that succeeds on the second attempt after the first timed out mid-loop can leave you with duplicate reminders, and a customer who receives two “your trial ends in 7 days” emails on the same morning reads it as a system that does not know its own state. Make the reminder rows idempotent at the schema level, not just in application logic.
The clamp on past offsets is subtle enough to deserve a concrete example. Suppose a customer starts a 2-day trial. The D-7 and D-3 offsets both resolve to send_at values before the trial even began, so the loop skips them and only the D-1 reminder survives. This is correct behavior — you do not want to send a “7 days left” email on a 2-day trial — but it means short trials silently lose most of their reminder coverage. If reminders are load-bearing for conversion, define a separate short-trial cadence rather than relying on the clamp to quietly drop offsets; the clamp is a safety net, not a scheduling policy.
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';
}
}
The idempotency key convert:${sub.id}:${sub.trialEndsAt} is constructed from two immutable facts, and that construction is the entire safety argument. Because trialEndsAt never changes for a given trial, every retry — the cron scan, a late webhook, a manual replay from an operator — computes the identical key, and the gateway collapses them into one charge. If you instead keyed on Date.now() or a per-attempt UUID, each retry would present a fresh key and the gateway would happily charge again; you would have built a retry loop that multiplies charges instead of deduplicating them. The one case that breaks this key is an explicit trial extension, which by design moves the anchor and therefore mints a new key — but that is correct, because an extended trial genuinely is a different conversion event at a different instant.
The catch block here is doing more than logging. A declined card, an expired card, and a gateway timeout all land in the same handler but demand different treatment, and collapsing them all to expired immediately is a mistake that costs real revenue. A hard decline (insufficient funds, card reported lost) is worth a short dunning sequence spread over days. A soft decline or a network timeout should be retried within minutes because the charge may actually have succeeded on the gateway side even though your request errored — which is exactly why the idempotency key matters on retry. Inspect the gateway’s decline code before deciding, and let enqueueDunning encode the retry cadence rather than treating every failure as terminal.
One guard that belongs in production code but is elided above: verify the subscription is still in a chargeable state before calling the gateway. Between the scan claiming the row and this function running, a customer may have canceled. Re-read the state inside the same transaction — or rely on the row lock held from the scan — so you never charge a customer_id who canceled seconds before the deadline. The race is narrow but at millions of trials it happens daily.
Edge Cases & Failure Modes
Trial bugs fall into three buckets: the anchor moves when it should not, the conversion charge fires the wrong number of times, or a webhook you were counting on never arrives. The map groups the failures so the defense — immutability, idempotency, or clock-driven fallback — is obvious.
| 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 |
The anchor-drift row is worth dwelling on because it is the most seductive bug in the table. A well-meaning “edit subscription” endpoint recomputes trial_ends_at as now() + trial_days whenever any field on the subscription is saved, reasoning that it is just keeping the record consistent. The result is that every unrelated edit — a plan-name typo fix, a metadata tag — silently pushes the trial deadline forward, and a customer who was three days from converting is quietly granted a fresh full trial. The defense is to make the anchor write-once at the persistence layer: only a dedicated, audited extension event may update the column, and every other save path must leave it untouched. Enforcing this with a database trigger or a column-level guard is stronger than trusting each code path to remember.
The double-charge and zero-charge rows are two faces of the same coin. A trial that is scanned by cron and nudged by a trial_will_end webhook fires the conversion twice, and only the idempotency key saves you; a trial whose card expired the week before the deadline fires zero successful charges and needs an account-updater refresh plus a proactive prompt. Instrument both directions: alert on any subscription with two charge attempts sharing an idempotency key that were not both collapsed, and alert on any trialing row whose vaulted card’s expiry precedes trial_ends_at. The first alert catches a broken key derivation; the second catches revenue you are about to lose to a stale card.
Performance & Scale
Trial management has exactly two hot queries — the conversion scan and the reminder drain — and both are batch jobs off the request path. Keep them indexed and parallelizable and the subsystem scales to millions of trials on one database. The diagram shows the two jobs and the indexes that keep them cheap.
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
Because everything hinges on time, a mock clock is the centerpiece of the test suite — it lets you fast-forward to any deadline deterministically. Around it sit an idempotent-conversion test, a short-trial reminder test, and a lost-webhook fallback test. The panel is the coverage floor.
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.
How do you stop customers from farming serial free trials? Decide the identity you dedupe on before you launch, because retrofitting it is painful. Email alone is trivially bypassed with plus-addressing and disposable domains, so most teams combine a normalized email (lowercased, dots and plus-tags stripped for the providers that ignore them) with a payment-instrument fingerprint from the vault and, for card-required trials, the network token that survives reissue. Record trial history keyed on that composite identity so a second trial attempt resolves to the same person and can be offered a paid plan instead of another free window. Keep the rule auditable and reversible — a false match that denies a legitimate customer their first trial is a worse outcome than the occasional farmed trial — so store the signals that drove a denial rather than a bare boolean.
Should the trial length itself be an experiment?
Yes, but change it prospectively and never retroactively. A customer who started a 14-day trial has a trial_ends_at computed from that promise; a later experiment that shortens new trials to 7 days must not move the existing customer’s end date, or you break the contract they signed up under and invite chargebacks. Model trial length as an attribute captured on the subscription at creation, not a global constant read at renewal, so two cohorts can run different lengths simultaneously and each customer’s trial always resolves against the length that was in force when they started.