Skip to content

Backend

Shipping Webhooks From Your SaaS — Publisher-Side Patterns

If your SaaS doesn't let customers subscribe to webhooks, integrators skip you. Here's the production pattern: subscriber table, delivery queue, signed payloads, retry backoff, and a dashboard for managing endpoints.

📤 🔗

Stripe ships `charge.succeeded` events to your webhook endpoint. GitHub fires `push` events when code lands. Shopify publishes order updates to customers' apps. Every serious API lets you subscribe to webhooks. If your SaaS doesn't, integrators avoid you. They want to build on-event automation: "When a user signs up, create a seat in our billing system." "When a project deploys, send a Slack notification." "When an invoice is issued, sync to accounting software." Building this is non-trivial on the publisher side — you need a durable delivery queue, exponential backoff retries, signed payloads, and a customer-facing dashboard to debug endpoint failures. Velocity X's multi-tenant SaaS architecture ships this from day one. Here's the real implementation: schema, delivery queue pattern, retry logic with exponential backoff, HMAC-SHA256 signatures, FAQs, and why skipping it costs you customers.

Why Webhooks Matter for SaaS Growth

Without webhooks, integrators poll your API on a cron schedule. "Check every 5 minutes if there's new data." This is wasteful, slow, and breaks the API rate limit. With webhooks, your SaaS fires an event the moment something happens. "User signed up? Webhook fires immediately." Integrators build better UX, lower latency, and trust you more. Companies like Stripe and GitHub publish 50+ event types. You don't need that many. Start with 3–4 core events: `user.created`, `project.deployed`, `invoice.issued`, `subscription.cancelled`. These cover 80% of integration use cases. Shipping webhooks also signals to the market that you're serious about developer experience. It's the difference between "small SaaS" and "platform people build on."

Schema: Subscriptions, Deliveries, and Events

Store three things: what endpoints customers subscribed to, delivery attempts and outcomes, and the event log. This lets you replay events, debug failures, and show customers their webhook health.

-- Webhook subscriptions: one row per customer endpoint
create table webhook_subscriptions (
  id uuid primary key default gen_random_uuid(),
  customer_id uuid not null references customers(id) on delete cascade,
  url text not null,
  event_types text[] not null, -- ['user.created', 'project.deployed']
  secret text not null, -- used for signing payloads
  is_active boolean default true,
  last_delivery_at timestamp,
  failure_count int default 0,
  created_at timestamp default now(),
  updated_at timestamp default now(),
  constraint unique_subscription unique(customer_id, url)
);

-- Delivery log: every attempt to send a webhook
create table webhook_deliveries (
  id uuid primary key default gen_random_uuid(),
  subscription_id uuid not null references webhook_subscriptions(id),
  event_id uuid not null,
  event_type text not null,
  status_code int, -- 200, 500, timeout, etc.
  response_body text,
  attempt_number int default 1,
  next_retry_at timestamp,
  error_message text,
  created_at timestamp default now()
);

-- Event log: immutable record of everything that happened
create table webhook_events (
  id uuid primary key default gen_random_uuid(),
  customer_id uuid not null references customers(id),
  event_type text not null, -- 'user.created', 'project.deployed'
  payload jsonb not null,
  created_at timestamp default now()
);

-- Indexes for performance
create index idx_webhook_subscriptions_customer on webhook_subscriptions(customer_id);
create index idx_webhook_deliveries_subscription on webhook_deliveries(subscription_id);
create index idx_webhook_deliveries_next_retry on webhook_deliveries(next_retry_at) where next_retry_at is not null;
create index idx_webhook_events_customer on webhook_events(customer_id);

The `webhook_subscriptions` table tracks each customer's endpoints and which events they subscribed to. The `secret` is generated when the endpoint is created — you use it to sign outgoing payloads (HMAC-SHA256). The `webhook_deliveries` table is your retry ledger: every attempt to send a webhook is logged here with the HTTP status, response body, and when to retry. The `webhook_events` table is the immutable source of truth — every event your SaaS creates (user signup, deployment, invoice) gets logged here regardless of delivery success. This separation lets you replay events to subscriptions that were offline.

Publishing Events and Queueing Deliveries

When something happens in your SaaS (user signs up, project deploys), insert a record into `webhook_events`, then create delivery tasks for every subscription subscribed to that event type. Use an edge function or serverless worker to handle the async queue.

// supabase/functions/webhook-dispatcher/index.ts
// Triggered by database insert on webhook_events

import { createClient } from '@supabase/supabase-js';
import { crypto } from 'https://deno.land/std/crypto/mod.ts';

const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);

// On webhook_events insert, find all subscriptions and queue deliveries
async function queueDeliveries(eventId: string, eventType: string, customerId: string) {
  // Find all subscriptions for this customer that want this event type
  const { data: subscriptions } = await supabase
    .from('webhook_subscriptions')
    .select('id, url, secret')
    .eq('customer_id', customerId)
    .contains('event_types', [eventType])
    .eq('is_active', true);

  if (!subscriptions) return;

  // Create a delivery task for each subscription
  const deliveries = subscriptions.map((sub) => ({
    subscription_id: sub.id,
    event_id: eventId,
    event_type: eventType,
    status_code: null,
    attempt_number: 1,
    next_retry_at: new Date().toISOString() // send immediately
  }));

  await supabase
    .from('webhook_deliveries')
    .insert(deliveries);

  console.log(`Queued ${deliveries.length} deliveries for event ${eventId}`);
}

// Worker that picks up pending deliveries and sends them
async function sendPendingDeliveries() {
  // Find deliveries due for retry
  const { data: pendingDeliveries } = await supabase
    .from('webhook_deliveries')
    .select(`
      id,
      subscription_id,
      event_id,
      event_type,
      webhook_subscriptions(url, secret),
      webhook_events(payload)
    `)
    .is('next_retry_at', null) // newly queued
    .or(
      'and(status_code.neq.200, next_retry_at.lte.now())'
    ) // retries that are due
    .limit(100);

  if (!pendingDeliveries || pendingDeliveries.length === 0) return;

  for (const delivery of pendingDeliveries) {
    const sub = delivery.webhook_subscriptions[0];
    const event = delivery.webhook_events[0];

    // Sign the payload with the subscription secret
    const payload = JSON.stringify(event.payload);
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const signature = await sign(payload, timestamp, sub.secret);

    try {
      const response = await fetch(sub.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Webhook-Signature': `t=${timestamp},v1=${signature}`,
          'X-Webhook-ID': delivery.id
        },
        body: payload,
        signal: AbortSignal.timeout(10000) // 10s timeout
      });

      // Success: mark as delivered
      if (response.ok) {
        await supabase
          .from('webhook_deliveries')
          .update({
            status_code: response.status,
            response_body: await response.text()
          })
          .eq('id', delivery.id);

        await supabase
          .from('webhook_subscriptions')
          .update({ last_delivery_at: new Date().toISOString(), failure_count: 0 })
          .eq('id', delivery.subscription_id);

        console.log(`Delivered webhook ${delivery.id}`);
      } else {
        // Retry with exponential backoff
        const nextRetry = calculateBackoff(delivery.attempt_number);
        await supabase
          .from('webhook_deliveries')
          .update({
            status_code: response.status,
            response_body: await response.text(),
            attempt_number: delivery.attempt_number + 1,
            next_retry_at: nextRetry
          })
          .eq('id', delivery.id);
      }
    } catch (error: any) {
      // Timeout or network error: retry
      const nextRetry = calculateBackoff(delivery.attempt_number);
      await supabase
        .from('webhook_deliveries')
        .update({
          error_message: error.message,
          attempt_number: delivery.attempt_number + 1,
          next_retry_at: nextRetry
        })
        .eq('id', delivery.id);

      console.log(`Failed webhook ${delivery.id}: ${error.message}`);
    }
  }
}

// Exponential backoff with max retries
function calculateBackoff(attemptNumber: number): string {
  const maxRetries = 8;
  if (attemptNumber >= maxRetries) return null; // give up

  // 5s, 30s, 2m, 5m, 30m, 2h, 5h, 24h
  const delays = [5, 30, 120, 300, 1800, 7200, 18000, 86400];
  const delaySecs = delays[Math.min(attemptNumber, delays.length - 1)];
  const nextTime = new Date(Date.now() + delaySecs * 1000);
  return nextTime.toISOString();
}

// HMAC-SHA256 signature
async function sign(payload: string, timestamp: string, secret: string): Promise {
  const data = `${timestamp}.${payload}`;
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
  const hex = Array.from(new Uint8Array(signature))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
  return hex;
}

Deno.serve(async (req) => {
  if (req.method === 'POST') {
    const { eventId, eventType, customerId } = await req.json();
    await queueDeliveries(eventId, eventType, customerId);
    return new Response(JSON.stringify({ queued: true }));
  }

  // Worker scheduled to run every 30 seconds
  await sendPendingDeliveries();
  return new Response(JSON.stringify({ processed: true }));
});

When a user signs up, your main flow inserts into `webhook_events` and calls this dispatcher function. It finds all subscriptions for that customer subscribed to `user.created`, creates delivery records with `next_retry_at = now()` (send immediately), and the worker loop picks them up and POSTs to each endpoint. If the endpoint returns 5xx or times out, the delivery is retried with exponential backoff (5s → 30s → 2m → 5m → 30m → 2h → 5h → 24h, then give up). On success, `failure_count` resets to 0. This pattern ensures no event is lost and slow/flaky endpoints don't block others.

Signing Payloads with HMAC-SHA256

Never trust the network. Customers need to verify that a webhook actually came from you, not a replay or man-in-the-middle attack. Sign every payload with HMAC-SHA256 using the subscription's secret.

When you send a webhook, include three headers: `X-Webhook-ID` (the delivery ID for idempotency), `X-Webhook-Signature` (the HMAC signature), and `Content-Type: application/json`. The signature format is `t=,v1=`. The customer verifies by computing HMAC-SHA256 of `.` using their secret, then comparing hex output to the `v1` value. If they match, the webhook is legit and wasn't modified in transit.

// Customer-side verification (Node.js example)
import crypto from 'crypto';

function verifyWebhook(req: any, secret: string): boolean {
  const sig = req.headers['x-webhook-signature']; // "t=1718123456,v1=abc123..."
  const rawBody = req.rawBody; // raw request body (not parsed JSON)

  if (!sig) return false;

  const [tPart, v1Part] = sig.split(',');
  const timestamp = tPart.replace('t=', '');
  const signature = v1Part.replace('v1=', '');

  // Prevent replay attacks: reject if timestamp is >5min old
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) return false;

  // Compute expected signature
  const data = `${timestamp}.${rawBody}`;
  const hmac = crypto
    .createHmac('sha256', secret)
    .update(data)
    .digest('hex');

  // Constant-time compare to prevent timing attacks
  return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(signature));
}

Customer Dashboard: Endpoints, Delivery Health, and Replay

Give customers a dashboard to manage their webhook endpoints. They should see: active subscriptions (URL + event types), delivery statistics (success rate, last delivery time), recent failures, and a button to replay past events. This transparency builds trust and makes debugging easier.

// Frontend: list subscriptions and delivery stats
const { data: subscriptions } = await supabase
  .from('webhook_subscriptions')
  .select(`
    id, url, event_types, is_active, last_delivery_at, failure_count,
    webhook_deliveries(
      status_code,
      attempt_number,
      created_at
    )
  `)
  .eq('customer_id', currentUser.customerId);

// Each subscription shows:
// - URL
// - Event types: user.created, project.deployed
// - Last delivery: 2 mins ago
// - Success rate: 98.5% (based on recent deliveries)
// - Failure count: 1 (non-2xx responses)
// - Action buttons: Test Endpoint, Replay Event, Disable/Enable

// Test endpoint: send a sample user.created event
const samplePayload = {
  id: 'evt_test_123',
  type: 'user.created',
  data: {
    user_id: 'user_xyz',
    email: 'test@example.com',
    created_at: new Date().toISOString()
  }
};

// Manually insert into webhook_events and let the dispatcher queue deliveries
await supabase
  .from('webhook_events')
  .insert({
    customer_id: currentUser.customerId,
    event_type: 'user.created',
    payload: samplePayload
  });

Six FAQs

How do I know if a webhook endpoint is dead?

Monitor `failure_count` on the `webhook_subscriptions` table. If it exceeds 3 consecutive failures, auto-disable the subscription and email the customer: "Your webhook endpoint at https://example.com/webhooks is failing. It returned a 500 error 4 times. Please fix it and re-enable in your dashboard." They can click to re-enable once they've fixed the endpoint. This prevents spamming their inbox with failed delivery notifications.

What if a customer loses their secret?

Let them regenerate it in the dashboard. When they do, old signatures become invalid, but new deliveries use the new secret. Advise them to update their verification code. You could also send a backup secret during endpoint creation (show it once, never again) so they can refer to setup docs.

Should I dedupe webhook events?

Yes. Use the `X-Webhook-ID` header: customers should treat the same `X-Webhook-ID` as the same event and skip re-processing if they've already seen it. At your end, you likely won't send the same event twice (webhook_events is the source of truth), but if a customer's endpoint is slow and your timeout fires, you might retry. The ID header lets them handle that gracefully.

How do I handle customers with many subscriptions?

One customer, one endpoint URL, multiple event types subscribed. Don't create separate subscriptions per event type — combine them. If they need different events to go to different places, they run multiple webhooks to different URLs (same customer, different subscriptions).

What if I need to add a new event type?

Add it to your event type constants, emit it when the event happens, and customers can subscribe via their dashboard. Old subscriptions aren't affected — they only receive events they're subscribed to. No schema migration needed.

Can customers filter events by metadata?

Not in this basic pattern. Start simple: subscriptions are event-type-based ("send me all user.created events"). Advanced filters (send me user.created events only for users in the "enterprise" tier) add complexity. Build that later if customers ask. Keep V1 simple.

The Bottom Line

Shipping webhooks from your SaaS signals that you take integrations seriously. It's a three-layer cake: subscriptions table (where do we send it), delivery queue (how do we send it reliably), and signatures (how do we prove it's real). Start with 3–4 events, add exponential backoff retries so flaky endpoints don't break everything, sign payloads with HMAC-SHA256 so customers trust what they receive, and give them a dashboard to see delivery health and replay events. Integrators will build on you. Investors notice. Your SaaS becomes a platform, not just a tool. Ready to build multi-tenant products with webhook integrations? Check Aidxn Design SaaS pricing for architecture partnerships. And for consumer-side webhook verification patterns, see Stripe webhooks on Netlify.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.