Credit-based SaaS billing is a clean fit for usage-driven products — pay for what you use, no annual prepay, no per-seat negotiation. Building it on Stripe is the easier part of the job. Making it auditable, idempotent and HMRC-satisfying is the harder part. This post walks through how vehReports runs credit-based billing end-to-end on Stripe in production, what we got wrong on the first attempt, and what we'd recommend to anyone building something similar.
The post is technical. If you're a buyer evaluating vehReports rather than a developer building a similar system, the short version is: every payment produces a VAT invoice from MATIC AUTOMOTIVE LIMITED, every credit transaction is logged, the audit trail satisfies HMRC, and idempotency is enforced at the webhook layer. The rest is implementation detail.
The model in one paragraph
Customer signs up → gets 10 free credits → uses them to generate reports → buys more via Stripe Checkout (one-time packs) or Stripe Subscriptions (monthly tiers with rolling credits) → each successful payment fires a Stripe webhook → our backend creates an order, allocates credits, sends a receipt. Each credit consumption is recorded against the user with the report ID it paid for. The whole thing has to be re-runnable in case a webhook fires twice.
Stripe Products + Prices
Each credit pack is a Stripe Product with a one-time Price in GBP. Each subscription tier is a Product with a recurring monthly Price. Products are created via the Stripe Dashboard (one-off) and synced to our products table for display on the pricing page.
We use Stripe Tax for VAT handling. The customer's tax address is captured at Checkout; Stripe applies UK VAT at the prevailing rate (currently 20%) for UK B2B customers, and the resulting invoice is VAT-compliant out of the box. The trick is making sure tax_behavior on each Price is set to exclusive — i.e. the Price is the ex-VAT amount, Stripe adds VAT on top. Get this wrong and your effective pricing is off by 20%.
The webhook handler
The critical path is the Stripe webhook handler. It handles two events:
checkout.session.completed— fires when a one-time credit bundle purchase completes.invoice.paid— fires when a subscription is charged (initial and recurring).
Both events translate to "credit the user with N credits and record the transaction." The actual code is straightforward; the gotchas are operational.
public function handle(Request $request)
{
// 1. Verify the signature
$payload = $request->getContent();
$sig = $request->header('Stripe-Signature');
$event = Webhook::constructEvent($payload, $sig, config('services.stripe.webhook_secret'));
// 2. Idempotency check — has this event been processed already?
if (StripeEvent::where('stripe_id', $event->id)->exists()) {
return response('OK', 200);
}
// 3. Process per event type
match ($event->type) {
'checkout.session.completed' => $this->handleCheckoutCompleted($event),
'invoice.paid' => $this->handleInvoicePaid($event),
default => null,
};
// 4. Mark processed
StripeEvent::create(['stripe_id' => $event->id]);
return response('OK', 200);
}
Three things this gets right that the first attempt got wrong:
Signature verification first. Anyone can POST to your webhook endpoint. Without signature verification, a bad actor can claim arbitrary payments succeeded. Stripe's signing-secret check stops that. Don't skip it.
Idempotency. Stripe will retry a webhook if it doesn't get a 2xx response within 30 seconds. If your handler takes 35 seconds (because you're calling another API in-line, or your DB is slow), Stripe retries — and now your handler runs twice for the same event. The StripeEvent table records every event ID we've successfully processed; a re-fire returns 200 immediately.
Late ACK only after credit allocation. Don't return 200 to Stripe until the credit allocation has committed to the database. If your handler returns 200, then your DB write fails, the user has paid but doesn't have credits. Always: process the work, then acknowledge.
The audit trail
Every credit transaction is a row in credit_transactions:
| Column | Purpose |
|---|---|
| user_id | who |
| credits_delta | +100 (purchase) or -1 (consumption) |
| source | 'stripe_bundle' / 'stripe_subscription' / 'report_consumption' / 'manual_adjustment' |
| related_id | order ID for purchases, report ID for consumption |
| stripe_event_id | for purchases, the originating webhook event ID |
| balance_after | for sanity-checking |
| timestamp | when |
This table is append-only. We never UPDATE a row — corrections happen by inserting a compensating transaction. That makes the audit trail bulletproof: at any point in time you can reconstruct the user's credit balance by summing all rows, and you can prove how they got there.
For HMRC purposes, this table plus the matching Stripe Dashboard view (which has the actual VAT invoice for each purchase) is the full audit trail.
What credit-based billing trades off vs subscription-only
You give up. Predictable monthly revenue, easy revenue forecasting, simple per-user pricing.
You gain. Tighter fit to customer value, lower friction at signup (no annual commitment), happier customers (they only pay for what they use), per-feature granularity (different actions can cost different credit amounts).
The hybrid model — subscription + credits — is usually the right answer for most B2B SaaS. Subscription gives you a revenue floor; credits handle variable usage. vehReports runs this hybrid: a Tier 1 subscription at £20/month (low predictable revenue) plus credit consumption at £1/report (variable, usage-driven).
Frequently asked questions
Can I bill credits without Stripe? Yes — any payment processor with webhook support works the same way. We use Stripe because it's the most operationally mature for UK SaaS, with native UK VAT handling and a developer ergonomics that's hard to beat.
How do I handle refunds?
A refund through Stripe fires charge.refunded. Our handler creates a compensating credit transaction (negative delta if the credits hadn't yet been consumed). If the user has already consumed credits the refund would now leave them with a negative balance — we handle that by writing the negative balance and offering the customer the choice of topping up or going to zero with no further consumption.
What about EU VAT post-Brexit? Stripe Tax handles both UK and EU VAT rules. The compliance work is in Stripe Dashboard configuration, not in your code. UK B2B sales charge UK VAT. EU B2B reverse-charge applies. Stripe gets this right by default if you configure your business's tax registrations.
What's the right idempotency window? We treat "have I seen this event_id before?" as the canonical check, with no time limit. Once an event is processed, it stays processed forever. Disk is cheap; double-charging is expensive.
Sources
- Stripe Webhooks documentation
- Stripe Tax — UK VAT handling
- Related feature: Credits & billing
- Related feature: Pricing
- Related: Pay-per-report vs per-seat fleet software