Concepts
Mapping Stripe webhooks to analytics events
What webhook to listen for, what conditions to check, and what analytics event to fire. Tool-agnostic.
If you're tracking revenue events in a SaaS that uses Stripe, you'll discover quickly that Stripe's webhook events don't map cleanly to the analytics events you want to track. This chapter covers the mapping — what Stripe webhook to listen for, what conditions to check, and what analytics event to fire. The mapping is tool-agnostic; the same logic applies whether you're sending events to Amplitude, PostHog, or anything else.
The example here uses a straightforward SaaS configuration:
- Single plan at a fixed monthly price
- 3-day free trial, no payment method required upfront
- Subscription becomes active after the trial converts (first successful charge)
- Users can cancel and resume freely; cancellation is scheduled for end of period
- No grace period — subscription expires immediately when canceled or when payment fails terminally
Your setup may differ. Where it differs, you'll need to adjust the conditions. The webhooks themselves are the same; what changes is what each webhook means in your specific context.
The core problem
Stripe's webhooks are designed for billing operations, not analytics. They tell you what changed in Stripe's system, not what meaningful analytics event occurred.
This creates several specific challenges.
One user action triggers multiple webhooks. A single "renew my subscription" cycle fires invoice.created, payment_intent.created, charge.succeeded, invoice.paid, and customer.subscription.updated. You need to pick the right one to track.
One webhook means many things. customer.subscription.updated fires for cancellations, plan changes, payment method updates, trial extensions, metadata edits, and dozens of other state changes. You can't just listen for it — you have to inspect what changed.
Status doesn't equal payment. When a trial ends, Stripe transitions the subscription from trialing to active and then creates the invoice. If you fire "Subscription started" on the status change, you'll track conversions that haven't actually paid yet.
Configuration changes the picture. Trial periods, proration behavior, grace periods, retry schedules — all of these affect which webhooks fire and when. There's no universal mapping.
The mapping
After working through this carefully, three webhooks cover the events you need to track.
| Analytics event | Stripe webhook |
|---|---|
| Trial started | invoice.paid |
| Subscription started (paid, no trial) | invoice.paid |
| Trial converted | invoice.paid |
| Subscription renewal | invoice.paid |
| Trial canceled | customer.subscription.updated |
| Trial resumed | customer.subscription.updated |
| Subscription canceled | customer.subscription.updated |
| Subscription resumed | customer.subscription.updated |
| Trial expiration | customer.subscription.deleted |
| Subscription expiration | customer.subscription.deleted |
The rest of this chapter explains how to distinguish each event within its webhook.
Payment events: invoice.paid
invoice.paid is the only reliable signal that money actually changed hands (including the €0 invoice that opens a trial). It fires four times in the lifecycle of a paying customer:
- When the trial subscription is created (€0 invoice, marks the trial start)
- When the trial converts (first non-zero invoice)
- When the subscription renews (every subsequent monthly invoice)
- For a direct paid subscription with no trial (the first invoice)
You distinguish them using two fields: invoice.billing_reason and the relationship between invoice.period_end and subscription.trial_end.
const hadTrial = subscription.trial_end !== null;
const isCreate = invoice.billing_reason === "subscription_create";
const isCycle = invoice.billing_reason === "subscription_cycle";
const isConversionFromTrial = invoice.period_end === subscription.trial_end;subscription.trial_end is populated when the subscription was created with a trial period. It stays on the subscription permanently — even after conversion or cancellation. So trial_end !== null reliably separates trial-based flows from direct purchases, regardless of when the webhook fires.
invoice.period_end is the end of the billing period this invoice covers. For a trial-converted invoice, period_end lands exactly on trial_end because the first paid period begins immediately after the trial. For any subsequent renewal, the period has rolled forward and the two timestamps no longer match.
Why not check invoice.amount_paid === 0?
You might think a trial invoice is identifiable by its zero amount. It is — until you ever use a 100% discount or credit. A fully discounted real subscription would look identical to a trial start. The billing_reason + trial_end approach holds regardless.
The mapping for invoice.paid
| Conditions | Event |
|---|---|
billing_reason === "subscription_create" AND trial_end !== null | Trial started |
billing_reason === "subscription_create" AND trial_end === null | Subscription started |
billing_reason === "subscription_cycle" AND period_end === trial_end | Trial converted |
billing_reason === "subscription_cycle" AND period_end !== trial_end | Subscription renewal |
Cancel and resume: customer.subscription.updated
customer.subscription.updated fires for any state change on the subscription. To detect cancellations and resumes specifically, you look at the previous_attributes field — Stripe includes only the keys that changed.
When the user cancels, Stripe sets cancel_at to the timestamp when the subscription will end (typically end of current period). When the user resumes, Stripe clears cancel_at back to null.
const wasCanceled = previous.cancel_at === null && subscription.cancel_at !== null;
const wasResumed = previous.cancel_at !== null && subscription.cancel_at === null;
const isTrial = subscription.status === "trialing";cancel_at is a scheduled cancellation — the subscription stays active until that timestamp, then terminates. This is different from immediate cancellation, which would delete the subscription right away and fire customer.subscription.deleted instead. Most SaaS apps use scheduled cancellation so users keep access through the end of their billing period.
To tell whether the action was on a trial or an active paid subscription, check subscription.status. A trial-in-progress subscription has status trialing; an active paid one has status active.
The mapping for customer.subscription.updated
| Conditions | Event |
|---|---|
previous.cancel_at null → timestamp AND status === "trialing" | Trial canceled |
previous.cancel_at timestamp → null AND status === "trialing" | Trial resumed |
previous.cancel_at null → timestamp AND status === "active" | Subscription canceled |
previous.cancel_at timestamp → null AND status === "active" | Subscription resumed |
customer.subscription.updated fires for many other reasons (plan changes, payment method updates, etc.) — none of those match the conditions above, so they don't produce analytics events from this handler.
Expiration: customer.subscription.deleted
When a subscription actually terminates, customer.subscription.deleted fires. This happens when a scheduled cancellation date arrives, when payment retries are exhausted, or when a trial ends without converting.
The webhook gives you the subscription's final state. It does not include previous_attributes — the subscription is gone, and Stripe just hands you what was true at termination. So you have to infer the type of expiration from what remains.
The trick: current_period_end moves forward with each billing cycle. If a trial expires without converting, the subscription never had a paid billing cycle, so current_period_end still equals the original trial boundary (which equals trial_end). If the subscription paid at least one cycle, the two values diverged.
const wasTrialExpiration = trial_end !== null && current_period_end === trial_end;The mapping for customer.subscription.deleted
| Conditions | Event |
|---|---|
current_period_end === trial_end (and trial_end !== null) | Trial expiration |
current_period_end !== trial_end | Subscription expiration |
A subscription with no trial that's deleted (direct paid sub that expired) falls into the second bucket — its trial_end is null, so the first condition is false.
The complete mapping table
| Analytics event | Webhook | Condition |
|---|---|---|
| Trial started | invoice.paid | billing_reason === "subscription_create" AND subscription.trial_end !== null |
| Subscription started | invoice.paid | billing_reason === "subscription_create" AND subscription.trial_end === null |
| Trial converted | invoice.paid | billing_reason === "subscription_cycle" AND invoice.period_end === subscription.trial_end |
| Subscription renewal | invoice.paid | billing_reason === "subscription_cycle" AND invoice.period_end !== subscription.trial_end |
| Trial canceled | customer.subscription.updated | previous.cancel_at null → timestamp AND status === "trialing" |
| Trial resumed | customer.subscription.updated | previous.cancel_at timestamp → null AND status === "trialing" |
| Subscription canceled | customer.subscription.updated | previous.cancel_at null → timestamp AND status === "active" |
| Subscription resumed | customer.subscription.updated | previous.cancel_at timestamp → null AND status === "active" |
| Trial expiration | customer.subscription.deleted | current_period_end === trial_end |
| Subscription expiration | customer.subscription.deleted | current_period_end !== trial_end |
Testing
These conditions need to be verified against your specific Stripe configuration. The way to do this:
- Enable Stripe test mode
- Walk through each subscription flow manually using test cards
- Log the raw webhook payloads — every field, not just the ones you're using
- Use Stripe's test clocks to fast-forward time and trigger trial conversions, renewals, and expirations in seconds
The conditions above work for the setup described at the top of this chapter. If your trial length is different, your billing interval is different, or you use proration or grace periods, some conditions will need adjustment. The webhook payloads tell you exactly what changed; you just have to look.
What's not covered
This mapping handles the lifecycle for a single-plan, single-currency, trial-then-paid SaaS. It doesn't handle:
- Plan upgrades and downgrades — these fire
customer.subscription.updatedwithprevious.planset. You'd add a condition checkingprevious.planto detect them. - Payment failures and retries — Stripe fires
invoice.payment_failed. If you want to track failed payments as analytics events, that's the webhook. - Refunds —
charge.refunded. If refunds are meaningful in your analytics, track them. - Multi-currency or proration — both work with the conditions above, but require extra properties on the events to interpret correctly.
Add these to your mapping as needed for your business.
Where the tool-specific code lives
This chapter covers what event to fire and when — but not how to actually send it to your analytics tool. That's tool-specific. For Amplitude, see Stripe revenue tracking. The same conditions translate directly to PostHog or Mixpanel; only the SDK calls change.