Skip to main content

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, crypto for 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

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.

Comments