Add Webhook Support to Your SaaS Boilerplate 2026
·StarterPick Team
webhookssvixsaas-boilerplatenextjsintegrations2026
TL;DR
Use Svix for production webhook infrastructure — it handles delivery, retries, signatures, and a ready-made developer portal. For DIY webhooks: BullMQ queue + HMAC-SHA256 signatures + exponential backoff retry. The SaaS pattern is: user registers endpoint URL → you send events → retry failed deliveries → show delivery logs. Webhooks are table stakes for B2B SaaS.
Key Takeaways
- Svix: Managed webhook service, portal UI, delivery logs, $99/month at scale
- DIY stack: BullMQ + Inngest for queuing,
cryptofor signatures, exponential backoff - Signatures: HMAC-SHA256 — let consumers verify payload authenticity
- Retries: Up to 72 hours with exponential backoff (industry standard)
- Events: Define a typed event catalog —
user.created,invoice.paid, etc. - Testing: Svix CLI for local forwarding; ngrok + manual triggers for DIY
Option 1: Svix (Managed, Recommended)
npm install svix
// lib/webhooks.ts — Svix webhook client:
import { Svix } from 'svix';
export const svix = new Svix(process.env.SVIX_AUTH_TOKEN!);
// Typed event catalog:
export type WebhookEvent =
| { type: 'user.created'; data: { userId: string; email: string } }
| { type: 'user.deleted'; data: { userId: string } }
| { type: 'subscription.created'; data: { userId: string; plan: string; amount: number } }
| { type: 'subscription.cancelled'; data: { userId: string; reason?: string } }
| { type: 'invoice.paid'; data: { invoiceId: string; amount: number; userId: string } };
// Send a webhook event to all subscriber endpoints:
export async function sendWebhookEvent(
organizationId: string, // Svix "app" ID (one per customer)
event: WebhookEvent
) {
try {
await svix.message.create(organizationId, {
eventType: event.type,
payload: {
type: event.type,
data: event.data,
timestamp: new Date().toISOString(),
},
});
} catch (error) {
// Svix handles retries — just log the initial failure
console.error(`Failed to queue webhook: ${event.type}`, error);
}
}
// Create Svix app per organization (on org creation):
export async function createWebhookApp(organizationId: string, orgName: string) {
const app = await svix.application.create({
name: orgName,
uid: organizationId, // Your org ID as Svix app UID
});
return app.id;
}
// Generate webhook portal URL (for customers to manage their endpoints):
export async function getWebhookPortalUrl(organizationId: string): Promise<string> {
const { url } = await svix.authentication.appPortalAccess(organizationId, {
expiry: 3600, // 1 hour
featureFlags: ['hideEventTypes'],
});
return url;
}
// app/api/settings/webhooks/portal/route.ts — redirect to Svix portal:
export async function GET() {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const org = await db.organization.findFirst({
where: { members: { some: { userId: session.user.id } } },
});
const portalUrl = await getWebhookPortalUrl(org!.id);
return Response.redirect(portalUrl);
}
// components/settings/WebhooksTab.tsx:
export function WebhooksTab() {
const openPortal = async () => {
const res = await fetch('/api/settings/webhooks/portal');
window.open(res.url, '_blank'); // Or redirect in same tab
};
return (
<div>
<h2>Webhooks</h2>
<p>Receive real-time notifications when events happen in your account.</p>
<Button onClick={openPortal}>
Manage Webhooks
</Button>
</div>
);
}
Triggering Webhooks in Your App
// Trigger webhooks after key events throughout your app:
// After user signup (in auth callback):
export async function afterUserCreated(user: User) {
await db.user.create({ data: { ... } });
// If user belongs to an org, send webhook:
if (user.organizationId) {
await sendWebhookEvent(user.organizationId, {
type: 'user.created',
data: { userId: user.id, email: user.email },
});
}
}
// After Stripe payment:
// app/api/webhooks/stripe/route.ts
if (event.type === 'invoice.payment_succeeded') {
const invoice = event.data.object as Stripe.Invoice;
const orgId = invoice.metadata?.organizationId;
if (orgId) {
await sendWebhookEvent(orgId, {
type: 'invoice.paid',
data: {
invoiceId: invoice.id,
amount: invoice.amount_paid / 100,
userId: invoice.metadata?.userId ?? '',
},
});
}
}
Option 2: DIY Webhooks with BullMQ
npm install bullmq ioredis
// lib/webhook-queue.ts — queue + delivery:
import { Queue, Worker, Job } from 'bullmq';
import { createHmac } from 'crypto';
import { db } from './db';
interface WebhookJob {
endpointId: string;
event: WebhookEvent;
attemptNumber: number;
}
export const webhookQueue = new Queue<WebhookJob>('webhooks', {
connection: { host: process.env.REDIS_HOST, port: 6379 },
defaultJobOptions: {
attempts: 8,
backoff: {
type: 'exponential',
delay: 1000, // 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s (~72 hours)
},
removeOnComplete: 100,
removeOnFail: 200,
},
});
// Sign payload for verification:
function signPayload(secret: string, timestamp: number, body: string): string {
const message = `${timestamp}.${body}`;
return createHmac('sha256', secret).update(message).digest('hex');
}
// Worker that delivers webhooks:
export const webhookWorker = new Worker<WebhookJob>(
'webhooks',
async (job: Job<WebhookJob>) => {
const { endpointId, event } = job.data;
const endpoint = await db.webhookEndpoint.findUnique({
where: { id: endpointId },
});
if (!endpoint?.enabled) return; // Skip disabled endpoints
const timestamp = Math.floor(Date.now() / 1000);
const payload = JSON.stringify({
type: event.type,
data: event.data,
timestamp: new Date().toISOString(),
});
const signature = signPayload(endpoint.secret, timestamp, payload);
const response = await fetch(endpoint.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Timestamp': timestamp.toString(),
'X-Webhook-Signature': `sha256=${signature}`,
'X-Webhook-Event': event.type,
},
body: payload,
signal: AbortSignal.timeout(10_000), // 10s timeout
});
// Log delivery attempt:
await db.webhookDelivery.create({
data: {
endpointId,
eventType: event.type,
statusCode: response.status,
success: response.ok,
attempt: (job.attemptsMade ?? 0) + 1,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: Webhook delivery failed`);
}
},
{ connection: { host: process.env.REDIS_HOST, port: 6379 } }
);
Webhook Signature Verification (for your consumers)
// How your API consumers verify webhooks:
// (Put this in your API docs/SDK)
import { createHmac, timingSafeEqual } from 'crypto';
export function verifyWebhookSignature(
payload: string,
timestamp: string,
signature: string,
secret: string
): boolean {
const message = `${timestamp}.${payload}`;
const expected = createHmac('sha256', secret).update(message).digest('hex');
const expectedBuffer = Buffer.from(`sha256=${expected}`);
const signatureBuffer = Buffer.from(signature);
// timingSafeEqual prevents timing attacks:
if (expectedBuffer.length !== signatureBuffer.length) return false;
return timingSafeEqual(expectedBuffer, signatureBuffer);
}
// In consumer's webhook handler:
app.post('/webhooks/yourapp', (req, res) => {
const isValid = verifyWebhookSignature(
req.body,
req.headers['x-webhook-timestamp'],
req.headers['x-webhook-signature'],
process.env.WEBHOOK_SECRET
);
if (!isValid) return res.status(401).send('Invalid signature');
const event = JSON.parse(req.body);
// Handle event...
res.status(200).send('ok');
});
Decision Guide
Use Svix if:
→ B2B SaaS where customers will set up webhooks
→ Want ready-made developer portal UI
→ Need delivery logs and retry dashboard
→ Don't want to maintain webhook infrastructure
→ Worth $99/month at any real scale
Use DIY (BullMQ) if:
→ Internal webhooks / team notifications only
→ Cost matters more than developer experience
→ You already have Redis + BullMQ
→ Simple use case (1-2 event types)
Webhook patterns for boilerplates:
→ Trigger from: auth events, Stripe webhooks, user actions
→ Always sign payloads with HMAC-SHA256
→ Always use exponential backoff (up to 72 hours)
→ Always log all delivery attempts
→ Rate limit endpoint registration (prevent abuse)
Find SaaS boilerplates with webhook support at StarterPick.