Back to Blog
billingstripesaas

Stripe Billing for an Agency: Recurring + One-Time Services with Webhook Idempotency

Ryan C·

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:

  1. I create the ProjectService in the admin dashboard (status: "pending")
  2. The system generates a Stripe Checkout session with the right line items
  3. The client clicks the payment link and completes checkout
  4. Stripe fires checkout.session.completed
  5. 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.

EventWhat It Does
checkout.session.completedActivate services, link subscriptions
customer.subscription.updatedSync status (active → past_due → cancelled)
customer.subscription.deletedMark cancelled, notify admin
invoice.paidRecord payment event with Stripe processing fee
invoice.payment_failedMark past_due, notify client + admin
charge.refundedRecord 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.

Want us to build something like this for you?

We ship production software in days, not months. Tell us what you need — our AI receptionist is standing by.

Back to Blog
Page

Page

Client AI · Online

Page

Hey, I'm Page.

Tell me what you need. I'll point you to the right person — or tell you if we're not the right fit.

Powered by Claude · Responses may vary