---
title: Mapping Stripe webhooks to analytics events
description: What webhook to listen for, what conditions to check, and what analytics event to fire. Tool-agnostic.
readingTime: 18 min
---

# Mapping Stripe webhooks to analytics events

> **Chapter 9 of the Concepts section.** Assumes you've read the earlier chapters in this section. The tool-specific implementation code lives in [Stripe revenue tracking](../amplitude/stripe-revenue.md).

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:

1. When the trial subscription is created (€0 invoice, marks the trial start)
2. When the trial converts (first non-zero invoice)
3. When the subscription renews (every subsequent monthly invoice)
4. 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`.

```ts
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.

```ts
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.

```ts
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:

1. Enable Stripe test mode
2. Walk through each subscription flow manually using test cards
3. Log the raw webhook payloads — every field, not just the ones you're using
4. 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.updated` with `previous.plan` set. You'd add a condition checking `previous.plan` to 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](../amplitude/stripe-revenue.md). The same conditions translate directly to PostHog or Mixpanel; only the SDK calls change.
