Subscription Billing in Laravel: Webhooks, Idempotency, and the Money Path
Billing is the one part of a SaaS where a bug doesn't show up as a broken layout — it shows up as a customer who paid and still has no access, or one who cancelled and keeps getting charged. Both end in support threads and refunds. The good news: almost every billing incident traces back to the same three design mistakes, and all three are avoidable.
The redirect is a hint, the webhook is the truth
The single most common mistake is granting access from the success redirect. Users close tabs, browsers drop the request, networks fail, and the redirect can be replayed. Treat the provider's webhook as the authoritative event and the redirect page as pure UI.
// routes/api.php — billing webhooks are stateless, session-free, CSRF-free
Route::post('/webhooks/billing', BillingWebhookController::class)
->withoutMiddleware(['web', 'csrf']);
Verify the signature before you parse anything, and reject on mismatch. An unverified webhook endpoint is a public API for giving away your product.
public function __invoke(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('X-Signature');
if (! Signature::verify($payload, $signature, config('billing.webhook_secret'))) {
abort(400, 'invalid signature');
}
$event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
// Store first, process later — the provider is waiting on us.
WebhookEvent::create([
'provider_event_id' => $event['id'],
'type' => $event['type'],
'payload' => $event,
]);
return response('ok', 200);
}
Returning 200 fast and processing in a queued job matters: providers retry on timeouts, and slow handlers are how you end up with duplicate work.
Idempotency is not optional
Providers deliver at least once. The same event.id will arrive twice eventually — on a retry, after a deploy, or because of a network hiccup on their side. Without a uniqueness guard you grant the same month twice or send duplicate invoices. A unique index on the provider's event ID turns "duplicate delivery" into a no-op:
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider_event_id')->unique();
$table->string('type')->index();
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
Wrap the whole handler in a database transaction and use the event row as a lock: if processed_at is already set, stop. Idempotency also applies to outbound calls — pass an idempotency key when you create a subscription, so a retry after a timeout doesn't create a second one.
Keep a local copy of subscription state
Don't query the provider on every page load to answer "is this account on the Pro plan?" Webhooks exist so you can mirror the state you need. Store status, plan, current_period_end, and cancel_at_period_end, and update them from events — not from your own assumptions about what a plan change should do.
subscription.created/updated→ sync plan and statussubscription.deleted→ drop access, keep the record for historyinvoice.payment_failed→ mark past due, start your dunning flowinvoice.paid→ extend the period, clear past due
Derive entitlements from that local row with a single method, and have the UI read from it. One source of truth in your database, one in the provider's, and an event stream that keeps them equal.
Handle the boring paths on purpose
The happy path takes an afternoon. The rest is where the incidents live:
- Past due. Failed card, retry schedule, grace period, then downgrade. Decide this in advance and write it down.
- Plan changes. Upgrades mid-cycle create proration; downgrades should usually apply at period end so the customer keeps what they paid for.
- Cancellation and resumption. Cancellation is not deletion — keep access until
current_period_end, and make resuming a first-class case. - Duplicates and out-of-order events. Compare timestamps from the payload instead of trusting arrival order.
Test the money path like production code
Billing deserves real tests, not mocks of the provider's SDK. Use the provider's CLI to forward live test events to your local endpoint, and write feature tests over recorded fixtures: duplicate delivery, out-of-order events, an unsigned payload, a failed payment, a mid-cycle upgrade. Assert on your local state and on the entitlement check — that's what the customer actually experiences.
The checklist
- Webhook is the source of truth; the success page is decoration
- Signature verified before parsing; handler returns 200 within milliseconds
- Unique index on provider event ID, processed in a queued job
- One local subscription row per customer, updated only by events
- Entitlements derived in one place, never recomputed in controllers
- Past due, proration, cancellation, and duplicate delivery all explicitly handled
- Real test fixtures for every unhappy path
Billing is unglamorous, but it's the code your business runs on. Verify the event, store it once, mirror the state, and test the paths nobody likes to think about — and you'll spend your support time on features instead of refunds.