# Publish events

> Define a typed event catalog, publish with idempotency keys, reach the right customer, and commit events with your own database writes.

Source: https://good-webhooks.dev/docs/guides/publish



`publish()` records an event and one delivery per subscribed endpoint. It sends nothing itself; a [worker](https://good-webhooks.dev/docs/operations/workers.md) does. The examples use the `webhooks` client from [installation](https://good-webhooks.dev/docs/installation.md#create-the-client).

```ts
import { webhooks } from './webhooks.js'

const { eventId, deliveryCount } = await webhooks.publish({
  type: 'invoice.paid',
  data: { invoiceId: 'inv_123', amount: 4200 },
})
```

`deliveryCount` is how many endpoints will receive it. Zero is not an error: nobody subscribed.

## Define your events [#define-your-events]

The event map is your public webhook catalog: names and payload schemas. Any [Standard Schema](https://standardschema.dev) validator works. Keep it in its own module so receivers can import it too.

```ts title="events.ts"
import { z } from 'zod'

export const events = {
  'invoice.paid': z.object({
    invoiceId: z.string(),
    amount: z.number().int().nonnegative(),
  }),
  'account.activated': z.object({ accountId: z.string() }),
}
```

Pass it to `createWebhooks({ events })`. TypeScript then checks every `publish()`, every endpoint subscription, and every `parseWebhook()` against it:

```ts
await webhooks.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_1', amount: '42' } })
//                                                                             ^ type error
```

Payloads are also validated at runtime before anything is stored.

**Payload and name rules**

* Validated output must be plain JSON: at most 256 KiB, 64 levels deep, finite numbers, no cycles.
* Event names match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$`.
* Prefer schemas that keep values as they are instead of transforming them, so receivers can reuse the same map. Convert dollars to cents before publishing, not inside the schema.

## Make retries of your code safe [#make-retries-of-your-code-safe]

Your own code can run twice: a retried job, a double-clicked button. Add an `idempotencyKey` built from the business fact:

```ts
const result = await webhooks.publish({
  type: 'invoice.paid',
  data: { invoiceId: 'inv_123', amount: 4200 },
  idempotencyKey: 'invoice.paid:inv_123', // [!code highlight]
})

result.duplicate // true when this key was already accepted
```

| You publish again with              | Result                                                  |
| ----------------------------------- | ------------------------------------------------------- |
| Same key, same type and payload     | The original event, `duplicate: true`. Nothing new sent |
| Same key, different type or payload | Throws `IDEMPOTENCY_CONFLICT`                           |
| A new key                           | A new event                                             |

Keys are unique per [scope](https://good-webhooks.dev/docs/concepts/scopes.md) and are released when the event is [pruned](https://good-webhooks.dev/docs/operations/retention.md) (seven days by default).

## Publish to a customer [#publish-to-a-customer]

The root client publishes in **application scope**: endpoints your platform registered for itself. To reach a customer's endpoints, publish in their scope:

```ts
const customer = webhooks.forScope({ type: 'organization', id: organizationId })
await customer.publish({ type: 'invoice.paid', data, idempotencyKey })
```

A scope only reaches endpoints registered in that exact scope. It does not check permissions: decide in your app that this action belongs to this customer first. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md).

## Publish inside your transaction [#publish-inside-your-transaction]

When your tables and the webhook tables share a database, pass your transaction client. The event then commits or rolls back with your data:

1. **BEGIN** (your app): Open a transaction on one client.
2. **Your write**: `UPDATE invoices SET paid = true …`
3. **publish()**: Same client, passed as `{transaction}`.
4. **COMMIT or ROLLBACK**: Commit: event and data exist. Rollback: neither does.

```ts
import { pool, webhooks } from './webhooks.js'

const client = await pool.connect()
try {
  await client.query('BEGIN')
  await client.query('UPDATE invoices SET paid = true WHERE id = $1', ['inv_123'])
  await webhooks.publish(
    { type: 'invoice.paid', data: { invoiceId: 'inv_123', amount: 4200 } },
    { transaction: client }, // [!code highlight]
  )
  await client.query('COMMIT')
} catch (error) {
  await client.query('ROLLBACK')
  throw error
} finally {
  client.release()
}
```

What to know:

* Workers can't see the event until you commit.
* A failed `publish()` doesn't break your transaction. It runs in a savepoint.
* Pass a checked-out client, not the pool, and don't run other queries on it at the same time.
* Finding recipients is a separate read from endpoint management. Passing a transaction does not pull a Better Auth database or a second PostgreSQL database into the commit.

Do recipients depend on the payload or on rows you haven't committed yet? Use [transactional recipient selection](https://good-webhooks.dev/docs/guides/recipient-selection.md).

## Limit load per endpoint [#limit-load-per-endpoint]

By default each endpoint gets at most 2 requests at a time, across all workers. Raise or lower it per endpoint:

```ts
await customer.deliverySettings.set(endpointId, { maxInFlight: 4 })
```

See [`deliverySettings`](https://good-webhooks.dev/docs/reference/delivery.md#deliverysettings).

## Next steps [#next-steps]

- [Run workers](https://good-webhooks.dev/docs/operations/workers.md): Send what you published.
- [Delivery guarantees](https://good-webhooks.dev/docs/concepts/delivery-guarantees.md): What a successful publish() promises.
