ResourcesGuidesSaaS Analytics GuideAmplitude implementationStripe revenue tracking

Amplitude implementation

Stripe revenue tracking

Server-side revenue events tied to Stripe webhooks. The most complex module, and the one that has to be right.

~26 min readUpdated May 2026Raw .md

This is the most complex module. Revenue events drive the most important business analyses (MRR, churn, conversion), and they have to be accurate. That means server-side, that means tied to Stripe webhooks, and that means careful handling of identifiers so the events tie back to user sessions.

The mapping of which Stripe webhook produces which analytics event is covered in Mapping Stripe webhooks. This chapter shows the Amplitude-specific implementation: how to actually fire those events.

The events

For the example SaaS in this guide (single plan, 3-day trial, no proration, no grace period):

Trial events:

  • Trial started
  • Trial canceled
  • Trial resumed
  • Trial converted
  • Trial expiration

Subscription events:

  • Subscription started (paid, no trial)
  • Subscription canceled
  • Subscription resumed
  • Subscription renewal
  • Subscription expiration

Each event updates the subscription_status user property to reflect the new state. Each carries $revenue for any event that involved money changing hands (so Amplitude's revenue analyses pick it up).

Including session and device IDs

Some revenue events are user-initiated (the user clicked "Start trial" or "Cancel subscription") and you want them attached to the user's active session. Others are system-initiated (renewals, trial conversions, expirations) and happen asynchronously — the user is likely offline.

Event typeUser present?Include session/device IDs?
Trial startedYesYes
Trial canceledYesYes
Trial resumedYesYes
Subscription startedYesYes
Subscription canceledYesYes
Subscription resumedYesYes
Trial convertedNoNo
Trial expirationNoNo
Subscription renewalNoNo
Subscription expirationNoNo

For user-initiated events, you need to make sure your server has access to the device ID and session ID at the moment the webhook fires. That's the trick — the webhook fires asynchronously from Stripe, after the user's session has moved on, and your server has no way to look up "what was the user's device ID when they clicked the button."

The solution: store the device and session IDs in Stripe's subscription metadata when the user initiates an action. When the webhook later fires, the metadata comes along with it.

Capturing IDs at checkout

When the user clicks "Start trial" or "Subscribe," pass the device and session IDs to your server, then attach them to the Stripe subscription's metadata.

Client side:

"use client";
 
import * as amplitude from "@amplitude/analytics-browser";
import { createCheckoutSession } from "./actions";
 
export function CheckoutButton({ priceId }: { priceId: string }) {
  const handleSubmit = async (formData: FormData) => {
    const deviceId = amplitude.getDeviceId();
    const sessionId = amplitude.getSessionId();
    
    if (deviceId) formData.set("amplitudeDeviceId", deviceId);
    if (sessionId) formData.set("amplitudeSessionId", sessionId.toString());
    
    await createCheckoutSession(formData);
  };
  
  return (
    <form action={handleSubmit}>
      <input type="hidden" name="priceId" value={priceId} />
      <button type="submit">Start trial</button>
    </form>
  );
}

Server side:

import Stripe from 'stripe';
 
export async function createCheckoutSession(formData: FormData) {
  const userId = await getCurrentUserId();
  const priceId = formData.get("priceId") as string;
  const deviceId = formData.get("amplitudeDeviceId") as string;
  const sessionId = formData.get("amplitudeSessionId") as string;
  
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${baseUrl}/dashboard?success=true`,
    cancel_url: `${baseUrl}/pricing`,
    subscription_data: {
      trial_period_days: 3,
      metadata: {
        user_id: userId,
        amplitude_device_id: deviceId,
        amplitude_session_id: sessionId,
      },
    },
  });
  
  redirect(session.url!);
}

Note subscription_data.metadata — not metadata directly on the session. The session's metadata stays on the session, but the subscription's metadata is what survives onto the subscription object, which is what your webhooks will receive.

The same applies for cancel/resume actions: if you let users cancel from your app, pass through the device and session IDs when calling stripe.subscriptions.update, and update the subscription metadata so the webhook fires with current session context.

The webhook handler structure

A single endpoint handles all Stripe webhooks. The handler dispatches by event type:

import { headers } from 'next/headers';
import Stripe from 'stripe';
import { handleInvoicePaid } from './handlers/invoice-paid';
import { handleSubscriptionUpdated } from './handlers/subscription-updated';
import { handleSubscriptionDeleted } from './handlers/subscription-deleted';
 
export async function POST(req: Request) {
  const body = await req.text();
  const sig = headers().get('stripe-signature');
  
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig!, webhookSecret);
  } catch (err) {
    return new Response('Webhook signature verification failed', { status: 400 });
  }
  
  switch (event.type) {
    case 'invoice.paid':
      await handleInvoicePaid(event.data.object as Stripe.Invoice);
      break;
    case 'customer.subscription.updated':
      await handleSubscriptionUpdated(event.data);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
      break;
  }
  
  return new Response(null, { status: 200 });
}

The mapping logic for each handler is in Mapping Stripe webhooks. The implementations below assume you've read that and understand the conditions.

Handler: invoice.paid

Fires for trial starts, subscription starts, trial conversions, and renewals.

import { track, identify, Identify } from '@amplitude/analytics-node';
import Stripe from 'stripe';
 
const eventConfig: Record<string, { includeSessionAndDevice: boolean }> = {
  "Trial started": { includeSessionAndDevice: true },
  "Subscription started": { includeSessionAndDevice: true },
  "Trial converted": { includeSessionAndDevice: false },
  "Subscription renewal": { includeSessionAndDevice: false },
};
 
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string;
  if (!subscriptionId) return;
  
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  
  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;
  
  let eventName: string | null = null;
  if (isCreate && hadTrial) eventName = "Trial started";
  else if (isCreate && !hadTrial) eventName = "Subscription started";
  else if (isCycle && isConversionFromTrial) eventName = "Trial converted";
  else if (isCycle && !isConversionFromTrial) eventName = "Subscription renewal";
  
  if (!eventName) return;
  
  const userId = subscription.metadata.user_id;
  const deviceId = subscription.metadata.amplitude_device_id;
  const sessionId = subscription.metadata.amplitude_session_id;
  const parsedSessionId = sessionId ? parseInt(sessionId, 10) : undefined;
  const { includeSessionAndDevice } = eventConfig[eventName];
  
  // Update user property first
  const identifyObj = new Identify();
  identifyObj.set("subscription_status", subscription.status);
  if (eventName === "Trial started" || eventName === "Subscription started") {
    identifyObj.set("current_plan", subscription.items.data[0].price.id);
    identifyObj.setOnce("first_subscription_date", new Date().toISOString());
  }
  await identify(identifyObj, { user_id: userId }).promise;
  
  // Track the event
  await track(
    eventName,
    {
      $revenue: invoice.amount_paid / 100,
      $currency: invoice.currency.toUpperCase(),
      $revenueType: eventName === "Subscription renewal" ? "renewal" : "initial",
      plan_id: subscription.items.data[0].price.id,
      subscription_status: subscription.status,
    },
    {
      user_id: userId,
      ...(includeSessionAndDevice && deviceId && { device_id: deviceId }),
      ...(includeSessionAndDevice && parsedSessionId && { session_id: parsedSessionId }),
    }
  ).promise;
}

A few notes:

  • invoice.amount_paid is in cents — divide by 100 for whole-currency values.
  • The $revenue, $currency, $revenueType properties are Amplitude reserved properties ($-prefixed) that power its built-in revenue analyses.
  • For Trial started, amount_paid is typically 0, which is fine — Amplitude handles zero-revenue events.

Handler: customer.subscription.updated

Fires for cancels and resumes (and many other state changes, which we ignore).

export async function handleSubscriptionUpdated(data: {
  object: Stripe.Subscription;
  previous_attributes?: Partial<Stripe.Subscription>;
}) {
  const subscription = data.object;
  const previous = data.previous_attributes || {};
  
  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";
  
  let eventName: string | null = null;
  if (wasCanceled && isTrial) eventName = "Trial canceled";
  else if (wasCanceled && !isTrial) eventName = "Subscription canceled";
  else if (wasResumed && isTrial) eventName = "Trial resumed";
  else if (wasResumed && !isTrial) eventName = "Subscription resumed";
  
  if (!eventName) return;
  
  const userId = subscription.metadata.user_id;
  const deviceId = subscription.metadata.amplitude_device_id;
  const sessionId = subscription.metadata.amplitude_session_id;
  const parsedSessionId = sessionId ? parseInt(sessionId, 10) : undefined;
  
  const identifyObj = new Identify();
  identifyObj.set("subscription_status", subscription.status);
  await identify(identifyObj, { user_id: userId }).promise;
  
  await track(
    eventName,
    { subscription_status: subscription.status },
    {
      user_id: userId,
      ...(deviceId && { device_id: deviceId }),
      ...(parsedSessionId && { session_id: parsedSessionId }),
    }
  ).promise;
}

All four events here are user-initiated, so device and session IDs are always included.

Handler: customer.subscription.deleted

Fires when a subscription actually ends.

export async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  const currentPeriodEnd = subscription.items.data[0].current_period_end;
  const trialEnd = subscription.trial_end;
  const wasTrialExpiration = trialEnd !== null && currentPeriodEnd === trialEnd;
  
  const eventName = wasTrialExpiration ? "Trial expiration" : "Subscription expiration";
  const userId = subscription.metadata.user_id;
  
  const identifyObj = new Identify();
  identifyObj.set("subscription_status", "canceled");
  await identify(identifyObj, { user_id: userId }).promise;
  
  await track(
    eventName,
    { subscription_status: subscription.status },
    { user_id: userId }
  ).promise;
}

Both expiration events are system-initiated, so no device or session IDs.

Testing

Before relying on this data, verify each path works. Stripe's test mode and test clocks make this straightforward.

Manual flows (test mode):

  1. Start a trial → check Amplitude for Trial started
  2. Cancel the trial → Trial canceled
  3. Resume the trial → Trial resumed

Time-based flows (test clocks):

  1. Fast-forward to trial end → Trial converted (if payment method works) or Trial expiration (if not)
  2. Fast-forward a month → Subscription renewal
  3. Cancel and fast-forward → Subscription expiration

For each event, verify:

  • Event name is correct
  • $revenue, $currency, $revenueType properties are set on payment events
  • subscription_status user property reflects the new state
  • Device and session IDs are present on user-initiated events and absent on system events
  • The event is attributed to the right user (user_id matches)

Common issues

Events firing twice. Usually means your webhook endpoint is slow to respond, so Stripe retries. Stripe expects a 200 within a few seconds. Acknowledge quickly and do heavy processing async if needed.

Missing conversion events. Often a timestamp precision or type issue with invoice.period_end === subscription.trial_end. Both should be Unix seconds as integers. Log both values and compare.

All events showing as system-initiated (no session IDs). The metadata isn't reaching the subscription. Verify you're passing amplitude_device_id and amplitude_session_id to subscription_data.metadata (not just metadata on the checkout session) when creating the subscription.

Events for the wrong user. user_id in subscription metadata is stale or incorrect. Verify you're setting it at subscription creation and that it matches your application's user ID.