Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Outbound webhooks in Next.js -- delivering signed events to customer endpoints with Drizzle ORM

June 19, 2026
nextjsdrizzle-ormsaaswebhooks

When your SaaS needs to notify another system -- a payment clears, a document finishes processing, a new user joins -- you need outbound webhooks. Your customer drops a URL into your settings page, and from that point on, your app POSTs events to it automatically.

The naive approach is to fire an HTTP request inline from your service layer. The customer's server is slow, your request times out, and now a core user action is blocked. Then you add retries and the logic spreads across five services.

The clean pattern: store endpoints in the database, emit events by writing a row, and deliver them via a background job.

The schema

Two tables handle the whole system:

export const webhookEndpointTable = pgTable('webhook_endpoints', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => userTable.id),
  url: text('url').notNull(),
  secret: text('secret').notNull(),
  events: text('events').array().notNull().default([]),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});
 
export const webhookDeliveryTable = pgTable('webhook_deliveries', {
  id: uuid('id').primaryKey().defaultRandom(),
  endpointId: uuid('endpoint_id').notNull().references(() => webhookEndpointTable.id),
  event: text('event').notNull(),
  payload: jsonb('payload').notNull(),
  status: text('status').notNull().default('pending'),
  attempts: integer('attempts').notNull().default(0),
  nextRetryAt: timestamp('next_retry_at').defaultNow().notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

webhook_endpoints holds each customer's registered URL and their HMAC signing key. webhook_deliveries is your queue and audit log -- one row per attempted delivery.

Emitting an event

Your service writes a delivery row. It does not make any HTTP request:

export async function emitWebhookEvent(
  userId: string,
  event: string,
  payload: unknown
) {
  const endpoints = await webhookRepo.findEndpointsForUser(userId, event);
  if (endpoints.length === 0) return;
 
  await db.insert(webhookDeliveryTable).values(
    endpoints.map((ep) => ({
      endpointId: ep.id,
      event,
      payload,
      status: 'pending',
      nextRetryAt: new Date(),
    }))
  );
}

Call it from any existing service after an action worth notifying:

// inside paymentService.createPayment()
await emitWebhookEvent(userId, 'payment.created', { amount, currency });

The payment succeeds even if the customer's webhook server is down. Delivery is fully decoupled from the action that triggered it.

Signing payloads

When the background job delivers, it signs the body with HMAC-SHA256 so the recipient can verify it came from you:

function signPayload(secret: string, body: string): string {
  return createHmac('sha256', secret).update(body).digest('hex');
}
 
const body = JSON.stringify({ event: delivery.event, data: delivery.payload });
const signature = signPayload(endpoint.secret, body);
 
await fetch(endpoint.url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Webhook-Signature': signature,
  },
  body,
  signal: AbortSignal.timeout(10_000),
});

The recipient recomputes the HMAC using the shared secret and compares it to the header. If they match, the request is authentic. Most integrations also add a timestamp field and reject requests older than five minutes to block replay attacks.

Retries via Vercel cron

A cron route runs every minute and processes pending deliveries:

// app/api/cron/webhooks/route.ts
export async function GET(req: Request) {
  if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }
  await webhookService.processPendingDeliveries();
  return Response.json({ ok: true });
}

On a failed delivery, the service increments attempts and sets nextRetryAt with exponential backoff -- 1 minute, then 5, then 30. After five attempts the status flips to failed and retries stop. Add a vercel.json entry to schedule it:

{
  "crons": [{ "path": "/api/cron/webhooks", "schedule": "* * * * *" }]
}

What your customers get

Every delivery row is an audit trail. A simple settings page listing the last 50 attempts -- event name, timestamp, HTTP status, retry count -- lets customers debug their integration without filing a support ticket.

The signing secret is generated with crypto.randomBytes(32).toString('hex') at endpoint creation and shown once, exactly like an API key. Store only the hash in the database if you want to be extra careful -- but unlike passwords, webhook secrets need to be recoverable for rotation UI, so storing the plaintext is common practice.


This entire pattern -- endpoint storage, decoupled event emission, signed delivery, exponential retry -- is already built into this boilerplate. Clone it and have your customer-facing webhook system running before lunch.