Stripe Billing for an Agency: Recurring + One-Time Services with Webhook Idempotency
Most Stripe tutorials assume you're building a SaaS with three pricing tiers. Agency billing is messier: monthly retainers, one-time project fees, custom discounts, and a client who wants it all on one invoice. Here's the data model and webhook architecture that made it work.
Billing at an agency isn't like billing for a SaaS product. You don't have one plan with three tiers. You have monthly retainers, one-time project fees, add-on services, mid-project scope changes, and clients who want everything on a single invoice. Stripe handles all of this, but the data model to make it work cleanly took me a few iterations.
The Data Model
The core abstraction is a Service — a thing I sell — and a ProjectService — an instance of that service attached to a specific client project.
Service (product catalog)
├── name: "Monthly Maintenance"
├── type: "recurring"
├── priceCents: 50000 # $500/month
├── billingInterval: "monthly"
└── stripeProductId: "prod_..."
ProjectService (billing line item)
├── projectId → Project
├── serviceId → Service
├── priceCents: 50000 # can override catalog price
├── discountPercent: 10 # per-client discounting
├── billingInterval: "monthly"
├── stripeSubscriptionId: "sub_..."
└── status: "active"
This separation matters. The Service catalog defines what I offer. The ProjectService tracks what each client is actually paying, including custom pricing and discounts. When I change a catalog price, existing clients aren't affected.
Checkout Flow
When a client needs to start paying for a service:
- I create the ProjectService in the admin dashboard (status: "pending")
- The system generates a Stripe Checkout session with the right line items
- The client clicks the payment link and completes checkout
- Stripe fires
checkout.session.completed - The webhook handler updates the ProjectService status to "active" and links the Stripe subscription ID
For one-time services, the status goes straight to "paid." For recurring services, the subscription lifecycle takes over.
The Six Webhooks
Webhooks are where Stripe tells your app what happened — a payment went through, a subscription was cancelled, a charge was refunded. I handle six event types, and each one has to be idempotent — meaning if Stripe sends the same event twice (which it does, by design), the second processing is a no-op.
| Event | What It Does |
|---|---|
checkout.session.completed | Activate services, link subscriptions |
customer.subscription.updated | Sync status (active → past_due → cancelled) |
customer.subscription.deleted | Mark cancelled, notify admin |
invoice.paid | Record payment event with Stripe processing fee |
invoice.payment_failed | Mark past_due, notify client + admin |
charge.refunded | Record negative payment event |
Idempotency is handled with a unique constraint on stripeEventId in the PaymentEvent table. If I've already processed an event, the database insert fails and I return 200 to Stripe without doing anything.
// If we've already processed this event, skip it
const existing = await prisma.paymentEvent.findUnique({
where: { stripeEventId: event.id }
})
if (existing) return NextResponse.json({ received: true })
Fee Tracking for Accounting
This is the detail that will save you hours at tax time, and most Stripe tutorials skip it. When invoice.paid fires, I don't just record the payment amount. I also fetch the balance_transaction from Stripe to get the processing fee.
const charge = event.data.object
const balanceTransaction = await stripe.balanceTransactions.retrieve(
charge.balance_transaction
)
const feeCents = balanceTransaction.fee // Stripe's cut
This matters at tax time. Your accountant wants to know gross revenue, processing fees, and net revenue. If you only track payment amounts, you're reconstructing fee data from Stripe's dashboard later — and it's painful.
MRR Calculation
Monthly Recurring Revenue sounds simple until you have annual subscriptions. An annual subscription at $6,000/year contributes $500/month to MRR, not $6,000 in the month it renews.
const mrr = activeServices.reduce((sum, service) => {
if (service.billingInterval === 'monthly') {
return sum + service.priceCents
} else if (service.billingInterval === 'annual') {
return sum + Math.round(service.priceCents / 12)
}
return sum // one-time services don't contribute to MRR
}, 0)
The revenue dashboard shows MRR, active subscription count, past-due count, and cancelled count. It's not Baremetrics, but it tells me what I need to know without another SaaS subscription.
The Accounting Export
At the end of each quarter, I export a CSV with:
- Every payment event (date, client, amount, fee, net, type)
- Monthly subtotals (gross, fees, refunds, net)
- Client-level breakdown (total revenue per client)
- Quarter/annual totals
My accountant gets a clean spreadsheet instead of me screen-sharing the Stripe dashboard for an hour.
Refund Handling
Refunds are recorded as negative PaymentEvents. The accounting export subtracts them from the period total. The client's billing page shows the refund in their payment history. Simple, but it took me two iterations to get the sign convention right in the CSV export.
Lessons Learned
Don't fight Stripe's model. I tried to build a custom billing system on top of Stripe and sync state bidirectionally. Bad idea. Now Stripe is the source of truth for payment state, and my database is a projection of Stripe events. Webhooks flow one way: Stripe → my database.
Test webhooks locally. stripe listen --forward-to localhost:3000/api/webhooks/stripe saved me hours of deploy-test cycles. Test every event type before going live.
Idempotency isn't optional. Stripe will retry failed webhook deliveries. Your handler will occasionally receive duplicate events. If you're not checking for duplicates, you'll double-count revenue. Ask me how I know.
