Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

### Added

- `brew.webhooks` manages brand-scoped email webhooks: `list`, `create`,
`get`, `update`, `delete`, `rotateSecret`, `test`, `events`, `attempts`,
`replay(webhookId, eventId)` and `verify`. `verifyWebhook` and
`BrewWebhookVerificationError` (with a `reason`) are also exported for
receivers without an API key. See `docs/webhooks.md`
(GetBrew/brew-v2#2398).

### Breaking

- `content.gif` `fps` for a `prompt` or `image` source now sets playback
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ controller is available from `@brew.new/sdk/embed`.
| Client configuration | [`docs/configuration.md`](./docs/configuration.md) |
| Contacts resource | [`docs/contacts.md`](./docs/contacts.md) |
| Domains resource | [`docs/domains.md`](./docs/domains.md) |
| Webhooks resource | [`docs/webhooks.md`](./docs/webhooks.md) |
| Emails resource (+ send) | [`docs/emails.md`](./docs/emails.md) |
| Fields resource | [`docs/fields.md`](./docs/fields.md) |
| Insights resource | [`docs/insights.md`](./docs/insights.md) |
Expand Down
1 change: 1 addition & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ what you know about the request:
| `BrewParseError` | The server answered 2xx, but the body was not JSON. Report it with `requestId`. |
| `BrewPaginationError` | A list endpoint repeated a cursor. The iterator stops with `code: 'PAGINATION_CURSOR_CYCLE'`, `cursor`, `pagesFetched`, and `itemsYielded`. Already-yielded items are incomplete; do not treat them as a complete list. |
| `BrewBrandExtractionTimeoutError` | `brands.waitForExtraction()` reached its total `maxWaitMs` deadline. Inspect `brandId` and `lastStatus`; extraction may still be running. |
| `BrewWebhookVerificationError` | `verifyWebhook()` refused an incoming webhook; `reason` is `headers`, `timestamp`, `signature` or `payload`. |
| your signal's `reason` | You cancelled — the `signal` you passed (request- or client-level) aborted. |

All SDK error classes extend `Error`, so the two-branch pattern below —
Expand Down
73 changes: 73 additions & 0 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Email webhooks

`brew.webhooks` manages brand-scoped email lifecycle and engagement subscriptions.
Use the `emails` API scope and select a brand for organization credentials.

```ts
const endpoint = await brew.webhooks.create({
endpoint: 'https://example.com/webhooks/brew',
events: ['email.delivered', 'email.bounced', 'email.complained'],
})
// Store endpoint.signing_secret in your server's secret manager.

const event = await brew.webhooks.verify({
payload: await request.text(),
headers: {
id: request.headers.get('svix-id') ?? '',
timestamp: request.headers.get('svix-timestamp') ?? '',
signature: request.headers.get('svix-signature') ?? '',
},
webhookSecret: process.env.BREW_WEBHOOK_SECRET!,
})
```

Verification uses Web Crypto and rejects timestamps outside five minutes. It
throws `BrewWebhookVerificationError`, whose `reason` is `headers`, `timestamp`,
`signature` or `payload`; answer `400` so the provider retries. Verify the
unmodified raw body: a string, or its bytes (an Express `raw()` `Buffer`).
`verifyWebhook` is also exported independently of the client, so a receiver
needs no API key.

Only the signature is authority. Brew adds event types and fields over time,
and `verify` returns any signed event envelope, so give your `switch
(event.type)` a default branch that acknowledges unknown types.

Methods: `list`, `create`, `get`, `update`, `delete`, `rotateSecret`, `test`,
`events`, `attempts`, `replay`, and `verify`. Identity-only methods take their
IDs positionally (`get(webhookId)`, `replay(webhookId, eventId)`); methods with
bodies or queries take an object. HTTP methods support the shared
`RequestOptions`, including `raw: true`, cancellation and idempotency keys.
For a manual retry after an uncertain creation response, reuse the same
`idempotencyKey` for the unchanged request. Use a new key for a changed request
or a new creation after confirmed success.

```ts
const page = await brew.webhooks.events({ webhookId: endpoint.id, limit: 20 })
const delivery = page.data[0]
if (delivery?.payload) {
await brew.webhooks.replay(endpoint.id, delivery.id)
}
```

Delivery is at least once and unordered. Deduplicate on the `svix-id` header.
`event.data.brew.event_id` names the underlying email fact; test events
(`data.brew.test: true`) all reuse one fixture `event_id`, so leave them out if
you deduplicate on it. Replay sends the same notification again, with the same
`svix-id`, without sending an email or changing email statistics. Customer event payload retention is 30 days; synthetic
fixtures may follow a different provider retention period.

`data.email_id` identifies a message, while `data.brew.design_id` identifies its
reusable design. Open/click events are raw tracking observations and may come
from scanners and privacy proxies. They do not establish human engagement or
inbox placement. Brew's final dashboard statistics apply additional filtering.

`email.failed` includes provider-reported failures and definitive recipient
rejections before acceptance. Pre-acceptance rejections count as pre-send skips
in analytics and do not reduce pending accepted messages. Rendering, admission,
and sender configuration failures do not emit lifecycle webhooks. Message
acceptance time uses mapping or provider evidence when available; otherwise
`data.created_at` falls back to the event occurrence time.

Bounce details can include `bounce.diagnosticCode`, an array of SMTP response
strings. Secret-rendered messages omit these diagnostics and free-form
`bounce.message` while retaining the bounce classification.
Loading
Loading