# Quick start

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

Source: https://good-webhooks.dev/docs/quick-start



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

What the quick-start script does:

1. **Register** (your app): Create an endpoint for a local receiver. Get its signing secret once.
2. **Publish** (your app): `publish()` validates the payload and stores one delivery per endpoint.
3. **Deliver** (worker): The worker signs the event and POSTs it to the receiver.
4. **Verify** (receiver): `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](https://good-webhooks.dev/docs/better-auth/quick-start.md) instead.

### Create a project [#create-a-project]

```sh
mkdir good-webhooks-quickstart && cd good-webhooks-quickstart
npm init -y
```

<CodeBlockTabs defaultValue="npm">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npm install good-webhooks@beta pg zod
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm add good-webhooks@beta pg zod
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn add good-webhooks@beta pg zod
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun add good-webhooks@beta pg zod
    ```
  </CodeBlockTab>
</CodeBlockTabs>

### Start PostgreSQL [#start-postgresql]

A throwaway database on port 55439:

```sh
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 [#write-the-script]

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

```ts title="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 [#run-it]

```sh
node quick-start.mts
```

You should see:

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

That's a signed [Standard Webhooks](https://www.standardwebhooks.com) request, sent by the worker and verified against the endpoint's secret.

### Clean up [#clean-up]

```sh
docker rm -f good-webhooks-quickstart
```

## From script to application [#from-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](https://good-webhooks.dev/docs/operations/migrations.md)  |
| `generateEncryptionKey()` on every run | Generate once, store in a secret manager, load in every process          | [Manage endpoints](https://good-webhooks.dev/docs/guides/endpoints.md) |
| `worker.runOnce()` in the same file    | A separate worker process calling `worker.run({ signal })`               | [Run workers](https://good-webhooks.dev/docs/operations/workers.md)    |
| `publish()` on its own                 | `publish()` inside the transaction that changes your data                | [Publish events](https://good-webhooks.dev/docs/guides/publish.md)     |
| The receiver only logs                 | The receiver records `event.id` with its change, so retries are harmless | [Receive webhooks](https://good-webhooks.dev/docs/guides/receive.md)   |

Drop `allowLocalhost` outside development. The [production checklist](https://good-webhooks.dev/docs/operations/production.md) collects the rest.

## Next steps [#next-steps]

- [Publish events](https://good-webhooks.dev/docs/guides/publish.md): Typed events, idempotency keys, and transactional publish.
- [Receive webhooks](https://good-webhooks.dev/docs/guides/receive.md): Verify in Express, Next.js, or Hono, and process each event once.
- [How it works](https://good-webhooks.dev/docs/concepts/how-it-works.md): The pieces behind the four stages above.
- [Complete examples](https://good-webhooks.dev/docs/examples.md): Two runnable apps with retries, replay, and a browser UI.
