Good Webhooks

Quick start

Send your first signed webhook in about five minutes. One database, one file, one command.

You will publish an invoice.paid event and watch a local receiver verify it. The script does four things:

  1. your appRegister

    Create an endpoint for a local receiver. Get its signing secret once.

  2. your appPublish

    publish() validates the payload and stores one delivery per endpoint.

  3. workerDeliver

    The worker signs the event and POSTs it to the receiver.

  4. receiverVerify

    parseWebhook() checks the signature and returns a typed event.

What the quick-start script does

You need Node.js 24 and Docker. Using Better Auth? Follow the Better Auth quick start instead.

Create a project

mkdir good-webhooks-quickstart && cd good-webhooks-quickstart
npm init -y
npm install good-webhooks@beta pg zod

Start PostgreSQL

A throwaway database on port 55439:

docker run --name good-webhooks-quickstart -d \
  -e POSTGRES_PASSWORD=dev_only -e POSTGRES_DB=webhooks \
  -p 127.0.0.1:55439:5432 postgres:16
until docker exec good-webhooks-quickstart pg_isready -h 127.0.0.1 -U postgres -d webhooks; do sleep 1; done

Write the script

Save this as quick-start.mts. The numbered comments match the four stages above.

quick-start.mts
import { once } from 'node:events'
import { createServer } from 'node:http'
import { Pool } from 'pg'
import { z } from 'zod'
import { createWebhooks, generateEncryptionKey } from 'good-webhooks'
import { getPostgresMigration } from 'good-webhooks/migrations'
import { parseWebhook } from 'good-webhooks/verify'

// Your event catalog. The publisher and the receiver share it.
const events = {
  'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }),
}

const pool = new Pool({
  connectionString: 'postgres://postgres:dev_only@127.0.0.1:55439/webhooks',
})

// Throwaway database only: create the tables once. Real apps use their migration runner.
const { rows } = await pool.query(`SELECT to_regclass('webhook_events') AS found`)
if (!rows[0].found) await pool.query(getPostgresMigration())

const webhooks = createWebhooks({
  database: pool,
  events,
  encryptionKey: generateEncryptionKey(), // Real apps load a stored key.
  allowLocalhost: true, // Development only: allows http://127.0.0.1 receivers.
})

// A receiver. Normally this is someone else's server.
let secret = ''
const receiver = createServer(async (request, response) => {
  const chunks: Buffer[] = []
  for await (const chunk of request) chunks.push(chunk)
  try {
    // 4. Verify: authenticate the raw bytes, then decode a typed event.
    const event = await parseWebhook({
      body: Buffer.concat(chunks),
      headers: request.headers,
      secret,
      events,
    })
    console.log(`✓ Receiver verified ${event.type} for ${event.data.invoiceId}`)
    response.writeHead(200).end()
  } catch (error) {
    console.error('✗ Receiver rejected the request:', error)
    response.writeHead(400).end()
  }
})
receiver.listen(0, '127.0.0.1')
await once(receiver, 'listening')
const { port } = receiver.address() as { port: number }

try {
  // 1. Register: the secret is returned once. Hand it to the receiver.
  const created = await webhooks.endpoints.create({
    url: `http://127.0.0.1:${port}/webhooks`,
    eventTypes: ['invoice.paid'],
  })
  secret = created.secret

  // 2. Publish: stores the event and one delivery per subscribed endpoint.
  const published = await webhooks.publish({
    type: 'invoice.paid',
    data: { invoiceId: 'inv_123', amount: 4200 },
  })
  console.log(`✓ Published to ${published.deliveryCount} endpoint`)

  // 3. Deliver: one batch. Production workers call run() and keep going.
  const result = await webhooks.worker.runOnce()
  console.log(`✓ Worker delivered ${result.succeeded}, failed ${result.failed}`)

  await webhooks.endpoints.remove(created.endpoint.id)
} finally {
  receiver.close()
  await pool.end()
}

Run it

node quick-start.mts

You should see:

✓ Published to 1 endpoint
✓ Receiver verified invoice.paid for inv_123
✓ Worker delivered 1, failed 0

That's a signed Standard Webhooks request, sent by the worker and verified against the endpoint's secret.

Clean up

docker rm -f good-webhooks-quickstart

From script to application

The script cut five corners. Each one has a guide:

In the scriptIn your applicationGuide
pool.query(getPostgresMigration())Your migration runner applies the SQL onceMigrations
generateEncryptionKey() on every runGenerate once, store in a secret manager, load in every processManage endpoints
worker.runOnce() in the same fileA separate worker process calling worker.run({ signal })Run workers
publish() on its ownpublish() inside the transaction that changes your dataPublish events
The receiver only logsThe receiver records event.id with its change, so retries are harmlessReceive webhooks

Drop allowLocalhost outside development. The production checklist collects the rest.

Next steps

On this page