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:
- your appRegister
Create an endpoint for a local receiver. Get its signing secret once.
- your appPublish
publish()validates the payload and stores one delivery per endpoint. - workerDeliver
The worker signs the event and POSTs it to the receiver.
- receiverVerify
parseWebhook()checks the signature and returns a typed event.
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 -ynpm install good-webhooks@beta pg zodStart 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; doneWrite the script
Save this as quick-start.mts. The numbered comments match the four stages above.
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.mtsYou should see:
✓ Published to 1 endpoint
✓ Receiver verified invoice.paid for inv_123
✓ Worker delivered 1, failed 0That's a signed Standard Webhooks request, sent by the worker and verified against the endpoint's secret.
Clean up
docker rm -f good-webhooks-quickstartFrom script to application
The script cut five corners. Each one has a guide:
| In the script | In your application | Guide |
|---|---|---|
pool.query(getPostgresMigration()) | Your migration runner applies the SQL once | Migrations |
generateEncryptionKey() on every run | Generate once, store in a secret manager, load in every process | Manage endpoints |
worker.runOnce() in the same file | A separate worker process calling worker.run({ signal }) | Run workers |
publish() on its own | publish() inside the transaction that changes your data | Publish events |
| The receiver only logs | The receiver records event.id with its change, so retries are harmless | Receive webhooks |
Drop allowLocalhost outside development. The production checklist collects the rest.