Most SaaS businesses don't recognize revenue the moment payment hits the bank—you need to recognize it as you deliver service. Stripe Billing handles the transaction mechanics, but you need to wire up webhooks and event tracking to capture revenue at the right moment. Let's show you how.
Understanding When Revenue is Recognized
Stripe gives you three moments to hook into: invoice finalized, invoice paid, or subscription activated. Pick the right one based on your accounting rules.
Define Your Revenue Recognition Trigger
In Stripe, revenue recognition typically happens when an invoice status changes, not when the payment settles. For most SaaS, you recognize revenue when the invoice is finalized or paid. If you're selling annual plans, you'll recognize revenue over 12 months using subscription schedules and tracking the invoice creation date.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// Retrieve an invoice to check its status
const invoice = await stripe.invoices.retrieve('in_xxxxx');
console.log(`Invoice ${invoice.id} status: ${invoice.status}`);
// Status can be 'draft', 'open', 'paid', 'uncollectible', 'void'
// List invoices for a subscription to build revenue history
const invoices = await stripe.invoices.list({
subscription: 'sub_xxxxx',
limit: 100
});
invoices.data.forEach(inv => {
const paidDate = inv.status_transitions.paid_at ? new Date(inv.status_transitions.paid_at * 1000) : null;
console.log(`Invoice ${inv.id}: ${inv.amount_paid / 100} USD on ${paidDate}`);
});Set Up Webhook Endpoints for Revenue Events
Go to your Stripe dashboard Developers > Webhooks and create a new endpoint. Add invoice.finalized and invoice.paid events. These events fire when Stripe moves the invoice through its lifecycle, giving you the signals you need to recognize revenue.
const express = require('express');
const app = express();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} 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 for invoice ${invoice.id}: ${invoice.amount_due / 100} USD`);
// INSERT INTO revenue_recognition_ledger ...
}
if (event.type === 'invoice.paid') {
const invoice = event.data.object;
console.log(`Payment confirmed for invoice ${invoice.id}`);
}
res.json({received: true});
});
app.listen(3000);charge.succeeded for revenue recognition—invoices can fail after payment settles. Use invoice events instead.Handling Multi-Month Revenue with Subscription Schedules
For annual or multi-month plans, you need to split revenue recognition across periods. Stripe Billing supports this through subscription schedules and the billing_cycle_anchor field.
Create a Subscription with Billing Cycle Anchor
When you create a subscription, set the billing_cycle_anchor to control when billing cycles start. This matters for revenue recognition—you want each period's revenue recognized at period start or invoice finalization, not all at once.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// Create annual subscription with monthly revenue recognition
const subscription = await stripe.subscriptions.create({
customer: 'cus_xxxxx',
items: [
{
price: 'price_xxxxx',
quantity: 1
}
],
billing_cycle_anchor: Math.floor(Date.now() / 1000),
expand: ['latest_invoice.payment_intent']
});
console.log(`Subscription created: ${subscription.id}`);
console.log(`Next billing: ${new Date(subscription.current_period_end * 1000)}`);
// For monthly revenue recognition of annual plans, use subscription schedules
const schedule = await stripe.subscriptionSchedules.create({
customer: 'cus_xxxxx',
phases: [
{
items: [
{
price: 'price_xxxxx',
quantity: 1
}
],
iterations: 12,
duration: { interval: 'month', interval_count: 1 }
}
]
});
console.log(`Schedule created: ${schedule.id}`);
// Stripe auto-generates 12 invoices, one per monthQuery Invoices by Date for Reporting
Pull invoices within a specific date range to build your revenue recognition reports. Filter by created timestamp and invoice status to match your accounting period cutoffs.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const startOfMonth = Math.floor(new Date('2024-03-01').getTime() / 1000);
const endOfMonth = Math.floor(new Date('2024-03-31').getTime() / 1000);
// Get all invoices created in March 2024
const invoices = await stripe.invoices.list({
created: {
gte: startOfMonth,
lte: endOfMonth
},
limit: 100,
status: 'paid'
});
let totalRevenue = 0;
invoices.data.forEach(invoice => {
const recognizedAmount = invoice.amount_paid / 100;
totalRevenue += recognizedAmount;
console.log(`Customer ${invoice.customer}: $${recognizedAmount.toFixed(2)}`);
});
console.log(`Total recognized revenue for March: $${totalRevenue.toFixed(2)}`);automatic_tax or coupons, the amount_due may differ from the base price. Use amount_paid for actual revenue recognized.Syncing Revenue Data to Your Analytics
Once you're capturing invoice events, push the data to your data warehouse so you can track MRR, ARR, and cohort revenue.
Transform and Load Invoice Data
Map Stripe invoice fields to your schema: id, customer, subscription, amount_paid, period_start, period_end, status. Filter out failed or voided invoices before loading into your database.
const transformInvoiceToRevenueRecord = (invoice) => {
return {
stripe_invoice_id: invoice.id,
stripe_subscription_id: invoice.subscription,
stripe_customer_id: invoice.customer,
amount_recognized_cents: invoice.amount_paid,
currency: invoice.currency,
period_start: new Date(invoice.period_start * 1000),
period_end: new Date(invoice.period_end * 1000),
invoice_date: new Date(invoice.created * 1000),
status: invoice.status,
is_paid: invoice.paid,
is_void: invoice.status === 'void'
};
};
// In your webhook handler:
if (event.type === 'invoice.finalized') {
const record = transformInvoiceToRevenueRecord(event.data.object);
// INSERT INTO revenue_recognition_ledger VALUES (...)
console.log(`Inserting revenue record:`, record);
}
if (event.type === 'invoice.paid') {
console.log(`Invoice ${event.data.object.id} marked as paid`);
}Common Pitfalls
- Recognizing revenue on
payment_intent.succeededinstead ofinvoice.paid—a payment can succeed but the invoice may be disputed, refunded, or voided later. - Forgetting to exclude test invoices—use
livemodefilter when querying to avoid mixing test data with production revenue. - Not handling refunds—when an invoice is refunded, Stripe creates a credit note. You need separate logic to reverse revenue recognition.
- Using
amount_dueinstead ofamount_paid—taxes, coupons, and credits change the recognized amount; always use actual paid amount.
Wrapping Up
You now have the wiring in place to recognize revenue the moment Stripe creates or finalizes invoices. Use subscription schedules for multi-month plans, webhooks for real-time signals, and invoice queries for period-end reporting. If you want to track this automatically across tools, Product Analyst can help.