Webhook Processing Architecture for Solo Founders
Webhooks are how your SaaS receives real-time event notifications from Stripe, Clerk, Twilio, GitHub, and every other third-party service in your stack. They look simple — an HTTP endpoint that receives a POST request — but the failure modes are numerous: duplicate events, out-of-order delivery, missed events during downtime, and signature verification bypasses. A poorly built webhook handler is a source of billing errors, data corruption, and security incidents.
This guide gives solo founders the minimum viable webhook architecture that handles all the important failure modes without overengineering.
🔐 Signature Verification First
Every webhook request must be verified before you process it. Without verification, anyone can POST to your webhook endpoint and trigger your business logic with fake events. Most providers sign their webhooks with a shared secret and include the signature in a header.
Stripe signature verification example:
import Stripe from 'stripe';
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response('Signature verification failed', { status: 400 });
}
// Safe to process event now
await processEvent(event);
return new Response('OK', { status: 200 });
}Always use the raw request body for signature verification — not the parsed JSON. Parsing first and then verifying against the parsed string will fail because JSON serialization can change whitespace and key ordering.
🔁 Idempotency: Handling Duplicate Events
Webhook providers guarantee at-least-once delivery — which means you will receive the same event more than once. If your handler processes a payment.succeeded event twice, you might provision an account twice, send two confirmation emails, or apply a discount twice. Idempotency prevents this.
Idempotency implementation:
CREATE TABLE processed_webhooks (
event_id TEXT PRIMARY KEY, -- provider's event ID
received_at TIMESTAMPTZ DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'processed'
);
-- In your handler:
async function processEvent(event) {
// Attempt to insert event ID — will fail if already processed
const { error } = await supabase
.from('processed_webhooks')
.insert({ event_id: event.id });
if (error?.code === '23505') {
// Unique constraint violation = duplicate event, skip safely
return;
}
// Process the event
await handleEvent(event);
}This pattern uses your database's uniqueness constraint as the idempotency lock. It is atomic, requires no additional infrastructure, and is correct under concurrent requests.
⚡ Fast Response, Async Processing
Webhook providers expect a 200 response within 5–30 seconds. If your handler times out, the provider retries the event — creating the duplicate delivery problem. The solution: acknowledge immediately, process asynchronously.
| Approach | Complexity | Best For |
|---|---|---|
| Respond 200, process synchronously | Low | Fast operations (<2 seconds); acceptable for most solo-stage products |
| Respond 200, enqueue job, process async | Medium | Slow operations (email, billing, provisioning); required for reliability |
| Respond 200, write to DB, background worker processes | Medium | High reliability; works with Supabase queues, Inngest, or a cron job |
For solo founders: start with synchronous processing for fast operations. Add a job queue (Inngest, BullMQ, or Supabase queues) when you have operations that take more than 2 seconds — provisioning accounts, sending transactional emails, or making downstream API calls.
📦 Webhook Event Router
As you add more event types, the webhook handler becomes a large switch statement. Structure it as a router from day one to keep it maintainable:
const eventHandlers: Record<string, (event: any) => Promise<void>> = {
'customer.subscription.created': handleSubscriptionCreated,
'customer.subscription.deleted': handleSubscriptionCancelled,
'invoice.payment_succeeded': handlePaymentSucceeded,
'invoice.payment_failed': handlePaymentFailed,
};
async function processEvent(event) {
const handler = eventHandlers[event.type];
if (!handler) {
console.log(`Unhandled event type: ${event.type}`);
return; // Return 200 anyway — do not return 400 for unknown events
}
await handler(event);
}Return 200 for unhandled event types. Returning 400 or 422 causes some providers to retry indefinitely, flooding your logs with events your system never intended to process.
What to Do Next
If you have a webhook endpoint with no signature verification: add it today — it is a 10-line change and the highest-priority security fix in your webhook stack. If you have no idempotency: add the processed_webhooks table and the duplicate-check before your next production incident. If your handler is a monolithic switch statement over 100 lines: refactor to the router pattern before adding the next event type.