Quick Start
This guide walks you through adding webhooks to an existing application. By the end, you’ll have an event defined, an endpoint registered, a webhook delivered, and its signature verified.
Prerequisites
Section titled “Prerequisites”- A running PostgreSQL 13+ database
- Node.js 22+
- An existing application with a database connection
-
Install
Terminal window npm install @usebetterdev/webhook @usebetterdev/webhook-verify zodInstall the adapter for your ORM:
Terminal window npm install @usebetterdev/webhook-drizzleTerminal window npm install @usebetterdev/webhook-prismaYou’ll install a framework adapter in step 5.
-
Run migrations
Terminal window npx @usebetterdev/webhook-cli migrate --database-url $DATABASE_URLThis creates the
webhook_endpoints,webhook_deliveries, andwebhook_delivery_attemptstables. -
Define an event
src/webhook/events.ts import { z } from "zod";import type { EventMap } from "@usebetterdev/webhook";export const events = {"user.created": {description: "A new user was created",schema: z.object({id: z.string(),email: z.string(),name: z.string(),}),},} satisfies EventMap; -
Create the webhook instance
src/webhook/instance.ts import { drizzle } from "drizzle-orm/node-postgres";import { Pool } from "pg";import { webhook, PollingRunner, parseEncryptionKeyFromEnv } from "@usebetterdev/webhook";import { drizzleWebhookAdapter } from "@usebetterdev/webhook/drizzle";import { events } from "./events.js";const pool = new Pool({ connectionString: process.env.DATABASE_URL });const db = drizzle(pool);const encryption = parseEncryptionKeyFromEnv(process.env.WEBHOOK_ENCRYPTION_KEY!);const adapter = drizzleWebhookAdapter(db);const runner = new PollingRunner({adapter,encryption,interval: 2000,concurrency: 5,});export const webhookInstance = webhook({events,adapter,jobRunner: runner,encryption,});runner.start();process.on("SIGTERM", () => void runner.stop());process.on("SIGINT", () => void runner.stop());src/webhook/instance.ts import { PrismaClient } from "@prisma/client";import { webhook, PollingRunner, parseEncryptionKeyFromEnv } from "@usebetterdev/webhook";import { prismaWebhookAdapter } from "@usebetterdev/webhook/prisma";import { events } from "./events.js";const prisma = new PrismaClient();const encryption = parseEncryptionKeyFromEnv(process.env.WEBHOOK_ENCRYPTION_KEY!);const adapter = prismaWebhookAdapter(prisma);const runner = new PollingRunner({adapter,encryption,interval: 2000,concurrency: 5,});export const webhookInstance = webhook({events,adapter,jobRunner: runner,encryption,});runner.start();process.on("SIGTERM", () => void runner.stop());process.on("SIGINT", () => void runner.stop()); -
Mount the API routes
Terminal window npm install @usebetterdev/webhook-honosrc/index.ts import { Hono } from "hono";import { createWebhookMiddleware } from "@usebetterdev/webhook-hono";import { webhookInstance } from "./webhook/instance.js";const app = new Hono();app.use("*", createWebhookMiddleware(webhookInstance, {basePath: "/api/webhooks",}));export default app;Terminal window npm install @usebetterdev/webhook-nodesrc/index.ts import express from "express";import { toNodeHandler } from "@usebetterdev/webhook-node";import { webhookInstance } from "./webhook/instance.js";const app = express();app.use("/api/webhooks", toNodeHandler(webhookInstance));app.listen(3000);Terminal window npm install @usebetterdev/webhook-nextapp/api/webhooks/[...path]/route.ts import { toNextJsHandler } from "@usebetterdev/webhook-next";import { webhookInstance } from "@/webhook/instance";export const { GET, POST, PATCH, DELETE } = toNextJsHandler(webhookInstance, {basePath: "/api/webhooks",}); -
Register an endpoint
Start your server, then create an endpoint that subscribes to
user.createdevents:Terminal window curl -s -X POST http://localhost:3000/api/webhooks/endpoints \-H "Content-Type: application/json" \-d '{"url": "https://example.com/my-webhook","events": ["user.created"]}'Response {"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890","url": "https://example.com/my-webhook","events": ["user.created"],"secret": "5f2b9a3e1c7d84f09b3e6a2d5c8f1e7a4b0d6c9f2a5e8b1d4f7a0c3e6b9d2f5","status": "active","createdAt": "2026-03-16T14:30:00.000Z","updatedAt": "2026-03-16T14:30:00.000Z"} -
Send your first event
Trigger an event from a route handler in your application:
src/index.ts import { webhookInstance } from "./webhook/instance.js";app.post("/users", async (c) => {// ... save user to database ...await webhookInstance.send("user.created", user);return c.json(user, 201);});src/index.ts import { webhookInstance } from "./webhook/instance.js";app.post("/users", async (req, res) => {// ... save user to database ...await webhookInstance.send("user.created", user);res.status(201).json(user);});app/api/users/route.ts import { webhookInstance } from "@/webhook/instance";export async function POST() {// ... save user to database ...await webhookInstance.send("user.created", user);return Response.json(user, { status: 201 });}The
PollingRunnerpicks up the delivery and POSTs it to every subscribed endpoint. -
Verify delivery
Check the delivery log via the API (use the endpoint ID from step 6):
Terminal window curl -s "http://localhost:3000/api/webhooks/deliveries?endpointId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"Response {"data": [{"id": "d8e9f0a1-b2c3-4567-890a-bcdef1234567","endpointId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890","eventName": "user.created","status": "delivered","attempts": 1,"maxAttempts": 5,"createdAt": "2026-03-16T14:30:01.000Z","updatedAt": "2026-03-16T14:30:01.000Z"}]}A
"status": "delivered"confirms the webhook reached your endpoint. -
Verify the signature (consumer side)
On the receiving end, verify that the webhook payload is authentic using
@usebetterdev/webhook-verify. TheassertWebhookSignaturefunction throws aWebhookSignatureErrorif verification fails. UseverifyWebhookSignatureinstead if you prefer a boolean return.consumer/src/index.ts import { Hono } from "hono";import { assertWebhookSignature } from "@usebetterdev/webhook-verify";const app = new Hono();app.post("/my-webhook", async (c) => {await assertWebhookSignature({headers: Object.fromEntries(c.req.raw.headers),body: await c.req.text(),secret: process.env.WEBHOOK_SECRET!, // the secret from step 6});const event = await c.req.json();console.log("Received:", event.type, event.data);return c.json({ received: true });});consumer/src/index.ts import express from "express";import { assertWebhookSignature } from "@usebetterdev/webhook-verify";const app = express();app.post("/my-webhook", express.text({ type: "application/json" }), async (req, res) => {await assertWebhookSignature({headers: req.headers,body: req.body,secret: process.env.WEBHOOK_SECRET!, // the secret from step 6});const event = JSON.parse(req.body);console.log("Received:", event.type, event.data);res.json({ received: true });});consumer/app/api/my-webhook/route.ts import { assertWebhookSignature } from "@usebetterdev/webhook-verify";export async function POST(request: Request) {const body = await request.text();await assertWebhookSignature({headers: Object.fromEntries(request.headers),body,secret: process.env.WEBHOOK_SECRET!, // the secret from step 6});const event = JSON.parse(body);console.log("Received:", event.type, event.data);return Response.json({ received: true });}The signature is verified using HMAC-SHA256 with the
X-Webhook-SignatureandX-Webhook-Timestampheaders. If the signature is invalid or the timestamp is too old (default: 5 minutes), verification fails.
What just happened?
Section titled “What just happened?”- You defined a
user.createdevent with a typed Zod schema. - The webhook instance registered the event and connected to your database via the ORM adapter.
- When you called
webhookInstance.send(), a delivery row was created for each subscribed endpoint. - The
PollingRunnerpicked up pending deliveries, signed the payload with HMAC-SHA256 using the endpoint’s secret, and POSTed it withX-Webhook-ID,X-Webhook-Timestamp, andX-Webhook-Signatureheaders. - Failed deliveries are retried automatically with exponential backoff (up to 5 attempts by default).
Next steps
Section titled “Next steps”- Configuration — encryption keys, retry strategies, batch size, hooks
- Defining Events — event schemas, payload validation, event catalog
- Security — signature scheme details, key rotation, HTTPS enforcement