Tutorial: endpoints for signed-in users

Build a billing app where each signed-in user registers, manages, and monitors their own webhook endpoints.

You will build a billing app where each signed-in user creates, lists, pauses, and deletes their own webhook endpoints. Users can set an optional gateway key that goes out as a request header. They see their signing secret once, and they see stats for their own deliveries.

The code comes from the Better Auth billing example. The example pins good-webhooks@0.6.0 and Better Auth 1.7.7, and this page was checked against both. CI also runs the plugin against the newest 1.7 and 1.8 releases. You need Node.js 24, a PostgreSQL database, and a Better Auth app with email and password sign-in. To run the finished app instead of typing it all, use the commands on the examples page.

ProcessRole
AppPages and auth routes
WorkerSends deliveries
ReceiverAccepts webhooks

The app serves the pages, the Better Auth routes, and the plugin's endpoint routes. It also writes invoices and publishes their events. The worker claims those events and delivers them. The receiver stands in for the endpoint owner's server: it verifies each signature, and in the example it also echoes the gateway key back.

Start from a Better Auth app

The example's auth options add the plugin to email and password sign-in:

src/auth.ts
import { betterAuth } from 'better-auth'
import { goodWebhooks } from 'good-webhooks/better-auth'
import type { Pool } from 'pg'
import { config } from './config.ts'
import { eventTypes } from './event-types.ts'
export function authOptions(database: Pool) {
  return {
    database,
    baseURL: config.appOrigin,
    secret: config.BETTER_AUTH_SECRET,
    trustedOrigins: [config.appOrigin],
    emailAndPassword: { enabled: true },
    plugins: [goodWebhooks({ eventTypes, allowLocalhost: true })], 
  }
}
export function createAuth(database: Pool) {
  return betterAuth(authOptions(database))
}
src/event-types.ts
// This module is safe to import in the browser.
export const eventTypes = ['invoice.created', 'invoice.paid'] as const

config.appOrigin is http://127.0.0.1:4312 in the example. trustedOrigins lists the origin the browser calls from. allowLocalhost: true lets endpoints point at the local receiver on 127.0.0.1. Use it for local development only.

as const keeps the literal event names, so the client types reject unknown names.

Apply the schema

The plugin adds one model to your Better Auth database. The example's setup script applies Better Auth's migrations in code, because its database is disposable:

src/setup.ts
const { getMigrations } = await import('better-auth/db/migration')
const { authOptions } = await import('./auth.ts')
// ...
await (await getMigrations(authOptions(pool))).runMigrations()

A real app runs the Better Auth CLI instead. See generate and apply the schema.

Add the client plugin

The browser client gets typed methods for the plugin's routes:

src/client.ts
import { createAuthClient } from 'better-auth/client'
import { goodWebhooksClient } from 'good-webhooks/better-auth/client'
import { eventTypes } from './event-types.ts'

const auth = createAuthClient({
  baseURL: location.origin,
  plugins: [goodWebhooksClient({ eventTypes })],
})

The example names the browser client auth; other pages call it authClient. Every client method returns { data, error }. The example unwraps each result and turns an error into an exception:

src/client.ts
function unwrap<T>(result: { data: T | null; error: { message?: string } | null }): T {
  if (result.error || result.data === null)
    throw new Error(result.error?.message ?? 'Request failed')
  return result.data
}

The Vanilla JS snippets below also use the example's small helpers from src/client.ts: $ selects an element, action runs a handler and shows its error, api calls the app's own routes, and updateMarkup writes HTML.

The React snippets keep the same page in one Endpoints component in src/endpoints.tsx. It imports auth, api, unwrap, and the Workspace type from a src/client.ts that exports them, keeps the page state in hooks, and its run function plays the part of action:

src/endpoints.tsx
import { useEffect, useState } from 'react'
import type { WebhookEndpointDTO } from 'good-webhooks/better-auth'
import { api, auth, unwrap, type Workspace } from './client.ts'
import { eventTypes } from './event-types.ts'

type Endpoint = WebhookEndpointDTO<(typeof eventTypes)[number]>

export function Endpoints() {
  const [workspace, setWorkspace] = useState<Workspace | null>(null)
  const [endpoints, setEndpoints] = useState<Endpoint[]>([])
  const [pendingSecret, setPendingSecret] = useState<{ endpointId: string; secret: string } | null>(
    null,
  )
  const [message, setMessage] = useState('')

  // Runs a handler and shows its error, like the example's `action` without its busy guard.
  async function run(work: () => Promise<void>) {
    try {
      await work()
    } catch (error) {
      setMessage(error instanceof Error ? error.message : 'Request failed')
    }
  }

  // The handlers below go here.

  return (
    <>
      <p>{message}</p>
      {/* The JSX below goes here. */}
    </>
  )
}

Create an endpoint with an optional gateway key

The endpoint form has a description, two event checkboxes, and an optional gateway API key. On submit, the client creates the endpoint and adds the key as an x-api-key header when the user filled it in:

src/endpoints.tsx
// In Endpoints():
async function create(form: FormData) {
  if (pendingSecret)
    throw new Error('Transfer or copy the current signing secret before creating another endpoint.')
  const apiKey = String(form.get('apiKey') ?? '').trim()
  const result = unwrap(
    await auth.goodWebhooks.create({
      description: String(form.get('description')),
      url: `${workspace!.receiverOrigin}/webhooks/${crypto.randomUUID()}`,
      eventTypes: eventTypes.filter((type) => form.has(type)),
      ...(apiKey ? { headers: { 'x-api-key': apiKey } } : {}), 
    }),
  )
  // ...
}
src/endpoints.tsx
// In the JSX:
<form
  onSubmit={(event) => {
    event.preventDefault()
    void run(() => create(new FormData(event.currentTarget)))
  }}
>
  <input name="description" placeholder="Endpoint name" required />
  <input name="apiKey" placeholder="Gateway API key (optional)" />
  {eventTypes.map((type) => (
    <label key={type}>
      <input type="checkbox" name={type} defaultChecked /> {type}
    </label>
  ))}
  <button>Create endpoint</button>
</form>

The headers go out on every delivery attempt, every retry, and every test event. The plugin stores them encrypted, like the signing secret. An endpoint has at most 20 headers, and 2 KiB in total as JSON. The management reference has the full rules.

Some names are reserved: names starting with webhook-, content-type, user-agent, and hop-by-hop names such as host. The route rejects them with HTTP 400 and the code INVALID_INPUT. The example's test checks the exact message:

test/workflow.test.ts
const reserved = await request('/api/auth/good-webhooks/create', alice.cookie, {
  url: `${receiverOrigin}/webhooks/${randomUUID()}`,
  eventTypes: ['invoice.created'],
  headers: { 'webhook-id': 'x' },
})
assert.equal(reserved.status, 400)
assert.equal(reserved.data.code, 'INVALID_INPUT')
assert.equal(reserved.data.message, 'Header webhook-id is reserved.')

In the browser, unwrap throws that message and the page shows it.

Show the signing secret once

The create result carries the signing secret. The client keeps it in pendingSecret and reveals the secret panel:

src/endpoints.tsx
// At the end of create():
setPendingSecret({ endpointId: result.endpoint.id, secret: result.secret })
await refresh()
src/endpoints.tsx
// In the JSX:
<>
  {pendingSecret && (
    <div>
      <input readOnly value={pendingSecret.secret} />
      <button onClick={() => void run(connectReceiver)}>Transfer to local receiver</button>
    </div>
  )}
</>

The Transfer to local receiver button posts the secret to the example's receiver and hides the panel:

src/endpoints.tsx
// In Endpoints():
async function connectReceiver() {
  if (!pendingSecret) return
  await api('/receivers/connect', pendingSecret)
  setPendingSecret(null)
  setMessage('Receiver connected. Create an invoice to send its first event.')
  await refresh()
}

Only the create and rotate responses carry the secret. Lists and other reads never do. In a real app, the owner pastes the secret into their receiver's configuration. The example's transfer route exists only because the example also owns the receiver.

To replace a secret, call auth.goodWebhooks.rotateSecret({ id }). It returns a new secret, and the old one stays valid for a grace period, 24 hours by default. See the route reference.

List the owner's endpoints

refresh() loads the app's own data and the owner's endpoints together:

src/endpoints.tsx
// In Endpoints():
async function refresh() {
  const [state, endpointResult] = await Promise.all([
    api<Workspace>('/workspace'),
    auth.goodWebhooks.list({}),
  ])
  setWorkspace(state)
  setEndpoints(unwrap(endpointResult))
}
useEffect(() => {
  void run(refresh)
}, [])

The page writes one card per endpoint. Trimmed and wrapped, the card shows the description, URL, event types, headers, status, and actions:

src/endpoints.tsx
// In the JSX:
<>
  {endpoints.length ? (
    endpoints.map((endpoint) => (
      <article key={endpoint.id}>
        <strong>{endpoint.description || 'Invoice endpoint'}</strong>{' '}
        {endpoint.status.replaceAll('_', ' ')}
        <p>{endpoint.url}</p>
        <p>{endpoint.eventTypes.join(', ')}</p>
        {Object.entries(endpoint.headers).map(([name, value]) => (
          <p key={name}>
            {name}: {value}
          </p>
        ))}
        <button
          onClick={() =>
            void run(() => operate(endpoint.id, endpoint.status === 'paused' ? 'resume' : 'pause'))
          }
        >
          {endpoint.status === 'paused' ? 'Resume' : 'Pause'}
        </button>
        <button onClick={() => void run(() => operate(endpoint.id, 'remove'))}>Remove</button>
      </article>
    ))
  ) : (
    <p>Create your first endpoint to receive invoice events.</p>
  )}
</>

An omitted scope means the signed-in user. headers is always present, {} when there are none. It comes back to the owner who set it. Another user sees none of it: the example's test proves that their list is empty and their get returns 404.

Pause, resume, and delete

Each button names the endpoint ID and the operation, and one handler calls the matching method:

src/endpoints.tsx
// In Endpoints():
async function operate(id: string, operation: 'pause' | 'resume' | 'remove') {
  if (operation === 'pause') unwrap(await auth.goodWebhooks.pause({ id }))
  if (operation === 'resume') unwrap(await auth.goodWebhooks.resume({ id }))
  if (operation === 'remove') {
    unwrap(await auth.goodWebhooks.remove({ id }))
    // ...
  }
  setMessage(
    `Endpoint ${operation === 'pause' ? 'paused' : operation === 'resume' ? 'resumed' : 'removed'}.`,
  )
  await refresh()
}

A pause holds new deliveries as pending. A resume sends them. After a removal, new events no longer select that endpoint. The example's test "pause holds delivery, resume sends it, and removal stops future selection" checks all three.

Change the headers later

update changes headers too. The plugin's route test covers these three cases; here they use the client method, since the example has no edit form:

// Replaces the whole set.
await auth.goodWebhooks.update({ id, headers: { authorization: 'Bearer t' } })

// Keeps the headers.
await auth.goodWebhooks.update({ id, description: 'changed' })

// Removes every header. `headers: {}` does the same.
await auth.goodWebhooks.update({ id, headers: null })

Check the header reaches the receiver

After it verifies the signature and records the invoice, the example's receiver echoes the key back:

src/receiver-app.ts
// A real gateway would check this key before the request reaches the app.
const apiKey = req.get('x-api-key') ?? null
res.json({ accepted: true, duplicate, apiKey })

The test checks both ends. The created endpoint returns the header, and the first attempt's response body contains the key:

test/workflow.test.ts
assert.deepEqual(created.data.endpoint.headers, { 'x-api-key': 'demo-key-123' })
// ...
assert.equal(JSON.parse(detail.data.attempts[0].responseBody).apiKey, 'demo-key-123')

The attempt panel in the example shows the receiver's response body, so you can see the echoed key in the browser. The echo is for this demo only. A real receiver must not return credentials in its response, because the worker stores each response body on the attempt record.

Publish in the owner's scope

Creating an invoice publishes invoice.created in the owner's scope. It uses the same PostgreSQL client as the invoice insert, so both commit or roll back together:

src/billing.ts
const publication = await delivery
  .forScope({ type: 'user', id: ownerId })
  .publish(
    { type: 'invoice.created', data, idempotencyKey: `invoice.created:${data.invoiceId}` },
    { transaction: client },
  )

Every /api route reads the owner from the session, never from the request body:

src/app.ts
app.use('/api', async (req, res, next) => {
  const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) })
  if (!session) {
    res.status(401).json({ error: 'Sign in first' })
    return
  }
  res.locals.userId = session.user.id
  next()
})

Publishing on the root engine uses application scope and never reaches a personal endpoint. To build the engine and run the worker, see add delivery.

Show the owner their delivery stats

The workspace route adds the owner's stats to its response:

src/app.ts
app.get('/api/workspace', async (_req, res) => {
  const owner = res.locals.userId as string
  const [invoices, receivers, outcomes, requests, deliveries, stats] = await Promise.all([
    // ...
    delivery.forScope({ type: 'user', id: owner }).deliveries.list({ limit: 50 }),
    delivery.forScope({ type: 'user', id: owner }).deliveries.stats(), 
  ])
  res.json({
    // ...
    deliveries,
    stats,
  })
})

The client writes one line under the delivery history heading:

src/endpoints.tsx
function Stats({ stats }: { stats: Workspace['stats'] }) {
  const { deliveries: counts, attemptsLastHour } = stats
  const attempts = Object.values(attemptsLastHour).reduce((sum, count) => sum + count, 0)
  return (
    <p>
      {counts.succeeded} succeeded, {counts.failed} failed, {counts.pending} pending. {attempts}{' '}
      {attempts === 1 ? 'attempt' : 'attempts'} in the last hour.
    </p>
  )
}
src/endpoints.tsx
// In the JSX of Endpoints():
<>{workspace && <Stats stats={workspace.stats} />}</>

deliveries.stats() returns counts by status, the age of the oldest pending delivery, and the last hour's attempts by outcome. See stats.

It reads every retained delivery in the scope. One user's scope is small, so the example calls it on each refresh. For the application scope, call it from a status page or a periodic check instead.

On this page