6 min read

How to Track Revenue Recognition in Stripe

Revenue recognition isn't when you get paid—it's when you've actually earned the money. In Stripe, subscriptions and invoices don't automatically tell you when revenue is recognized; you need to capture the right events and timestamps. This guide shows you how to track revenue recognition properly in Stripe instead of guessing based on payment dates.

Capture Invoice Events via Webhooks

The foundation of revenue tracking in Stripe is listening to invoice lifecycle events. You need to know when an invoice is finalized (recognized), not just when it's paid.

Set up a webhook endpoint for invoice events

Create an endpoint that receives Stripe webhook events. Focus on invoice.finalized (when revenue is recognized) and invoice.payment_succeeded (when payment clears). Use the Stripe CLI locally to test webhooks before deploying.

javascript
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'invoice.finalized') {
    const invoice = event.data.object;
    console.log(`Revenue recognized: $${(invoice.amount_due / 100).toFixed(2)}`);
  }

  res.status(200).json({received: true});
});
Webhook handler that captures invoice.finalized events

Map revenue to the subscription period, not payment date

For subscriptions, revenue is recognized at the start of each billing period. Use period_start from the invoice object as your revenue recognition date, not the payment timestamp.

javascript
if (event.type === 'invoice.finalized') {
  const invoice = event.data.object;
  const periodStart = new Date(invoice.period_start * 1000);
  
  console.log(`Invoice finalized for period: ${periodStart.toISOString().split('T')[0]}`);
  
  // Record revenue on period start, not when paid
  sendToAccounting({
    amount: invoice.amount_due / 100,
    recognitionDate: periodStart,
    invoiceId: invoice.id
  });
}
Use period_start as recognition date for subscriptions
Watch out: invoice.created fires before finalization. Use invoice.finalized to get the actual revenue recognition timestamp.

Add Metadata to Segment Revenue

Stripe webhooks give you the invoice, but metadata helps you add business context—customer segment, product tier, cost center—so you can slice revenue later without rebuilding data.

Attach metadata when creating subscriptions

Include metadata fields on subscriptions so they flow through to invoices automatically. This tags revenue at the source without requiring separate lookup tables.

javascript
const subscription = await stripe.subscriptions.create({
  customer: customerId,
  items: [{
    price: 'price_1234567890',
  }],
  metadata: {
    customerSegment: 'enterprise',
    productTier: 'pro',
    costCenter: 'sales'
  }
});
Metadata persists through the subscription lifecycle and appears on every invoice

Filter invoices by metadata in reports

Use the Stripe API to retrieve and aggregate invoices by your custom metadata fields. This lets you segment revenue without joining against your own database.

javascript
const invoices = await stripe.invoices.list({
  limit: 100,
  status: 'paid'
});

const enterpriseRevenue = invoices.data
  .filter(inv => inv.subscription)
  .map(inv => {
    const sub = await stripe.subscriptions.retrieve(inv.subscription);
    return {
      amount: inv.amount_paid / 100,
      segment: sub.metadata?.customerSegment,
      date: new Date(inv.period_start * 1000).toISOString().split('T')[0]
    };
  })
  .filter(item => item.segment === 'enterprise')
  .reduce((sum, item) => sum + item.amount, 0);

console.log(`Enterprise revenue: $${enterpriseRevenue.toFixed(2)}`);
Tip: Keep metadata flat (no nested objects). Stripe flattens them during storage, making queries harder if you nest.

Track Adjustments and Refunds

Credits, discounts, and refunds reduce the revenue you actually recognize. You need to track these as adjustments to the original period, not separate transactions.

Capture invoice adjustments before finalization

Listen to invoice.updated to catch when credits or discounts are applied. Use the adjusted amount_due, not the original amount, as your revenue figure.

javascript
if (event.type === 'invoice.updated') {
  const invoice = event.data.object;
  const prevInvoice = event.data.previous_attributes;
  
  if (prevInvoice.amount_due && prevInvoice.amount_due !== invoice.amount_due) {
    const creditApplied = (prevInvoice.amount_due - invoice.amount_due) / 100;
    console.log(`Invoice adjusted. Credit: $${creditApplied.toFixed(2)}`);
    
    // Update revenue to the adjusted amount
    updateRevenue(invoice.id, invoice.amount_due);
  }
}
Track invoice adjustments to record the correct revenue amount

Record refunds as negative revenue in the original period

When a refund is issued, create a negative revenue entry for the original billing period. Use the refund timestamp and original invoice's period start to match it correctly.

javascript
if (event.type === 'charge.refunded') {
  const charge = event.data.object;
  
  const invoices = await stripe.invoices.list({
    customer: charge.customer,
    limit: 10
  });
  
  const originalInvoice = invoices.data.find(inv => 
    inv.charge === charge.id || inv.payment_intent === charge.payment_intent
  );
  
  if (originalInvoice) {
    console.log(`Refund: -$${(charge.amount_refunded / 100).toFixed(2)} for period ${new Date(originalInvoice.period_start * 1000).toISOString().split('T')[0]}`);
    
    // Record as negative revenue in the original period
    sendToAccounting({
      amount: -(charge.amount_refunded / 100),
      recognitionDate: new Date(originalInvoice.period_start * 1000),
      invoiceId: originalInvoice.id,
      type: 'refund'
    });
  }
}
Link refunds back to their original invoice period for accurate reconciliation
Watch out: Refunds can apply to partial amounts across multiple invoices. Always tie refunds to the original period, not the refund date.

Common Pitfalls

  • Using invoice.paid or payment timestamp instead of invoice.finalized—revenue is recognized when Stripe finalizes the invoice, not when money lands.
  • Treating invoice amount as revenue without accounting for credits, discounts, and prior balance adjustments that reduce the final amount.
  • Ignoring subscription period dates and using invoice creation or payment dates—revenue is recognized on period_start, not payment date.
  • Not tracking refunds as adjustments to the original period—refunds are negative revenue entries tied to the billing period they reverse, not standalone events.

Wrapping Up

Revenue recognition in Stripe requires capturing the right events (invoice.finalized), using correct timestamps (period_start for subscriptions), and tracking adjustments (credits, refunds) as separate entries. This gives you a single source of truth for revenue timing that matches accounting standards. If you want to track this automatically across tools, Product Analyst can help.

Track these metrics automatically

Product Analyst connects to your stack and surfaces the insights that matter.

Try Product Analyst — Free