# Contributing
# Contributing
Thanks for your interest in contributing to Better Webhook.
## Prerequisites
* Install [devbox](https://github.com/jetify-com/devbox) to manage tools and dependencies.
## Getting started
1. Fork and clone the repository.
2. Install dependencies:
```bash
devbox run -- pnpm install --frozen-lockfile
```
3. Run checks before opening a pull request:
```bash
devbox run -- pnpm run format:check
devbox run -- pnpm run lint
devbox run -- pnpm run check-types
devbox run -- pnpm run test
devbox run -- pnpm run build
```
4. Run the security scan for the changes you added:
```bash
devbox run -- just security-scan
```
## Development guidelines
* Keep changes focused and small when possible.
* Add tests for behavioral or security-sensitive changes.
* Update docs when user-facing behavior changes.
* Follow existing package and workflow conventions.
## Pull requests
* Use a clear title that explains intent.
* Include a concise summary and test plan.
* Link related issues when applicable.
## Security reports
Do not disclose security vulnerabilities publicly.
Report them through the security policy in `SECURITY.md`.
# FAQ
# FAQ
## Which webhook providers are supported?
The SDK includes providers for:
* GitHub
* Ragie
* Recall.ai
You can also define custom providers using `@better-webhook/core`. See [Providers](/docs/sdk/providers#custom-providers).
## Which frameworks are supported?
Official adapters are available for:
* Next.js
* Express
* NestJS
* Hono
* GCP Cloud Functions
See [Adapters](/docs/sdk/adapters) for setup details.
## Can I use Better Webhook without exposing localhost publicly?
Yes. For replay and template-based testing, localhost is enough.
For receiving live events from external services, your provider still needs to reach your capture endpoint. If localhost is unreachable from the provider, use a tunnel.
## Does Better Webhook verify signatures automatically?
Yes, when secrets are configured. You can provide secrets through adapter options, provider options, or provider-specific environment variables. See [Security](/docs/security).
## Why do I get `204` responses from the SDK?
`204` usually means no matching handler executed for that event, or no response body is returned by design. This can be expected behavior for unhandled events.
## How are releases published?
SDK packages are published through Changesets from `main`.
See [Release Policy](/docs/release-policy).
# Better Webhook
import { Cards, Card } from "fumadocs-ui/components/card";
import { Code2 } from "lucide-react";
# Better Webhook
Better Webhook helps you build safer webhook handlers with typed payloads, schema validation, and signature verification.
## The Problem
Building webhook handlers is painful:
* **No type safety** — Webhook payloads are untyped `any` objects, leading to runtime errors
* **Manual verification** — Implementing HMAC signature verification correctly is error-prone and tedious
* **Adapter differences** — Each framework exposes raw bodies, headers, and responses differently
## The Solution
Better Webhook provides SDK packages that solve these problems:
} title="SDK Packages" description="Type-safe webhook handlers with automatic signature verification. Works with Next.js, Hono, Express, NestJS, and GCP Cloud Functions." href="/docs/sdk" />
## Quick Comparison
| Need | Package area |
| ---------------------------------------- | ------------ |
| Type-safe webhook handlers in production | SDK |
| Automatic signature verification | SDK |
| Zod schema validation for payloads | SDK |
| Framework-specific adapters | SDK |
## Choose Your Path
**Building webhook handlers?** → [Get started with the SDK](/docs/sdk)
## Additional Guides
* [Security](/docs/security) — Signature verification, secret handling, and replay protection
* [Troubleshooting](/docs/troubleshooting) — Common setup and runtime fixes
* [FAQ](/docs/faq) — Quick answers for common questions
* [Contributing](/docs/contributing) — Local workflow and contribution standards
* [Release Policy](/docs/release-policy) — SDK release mechanics and versioning
# Release Policy
# Release Policy
## SDK package releases
SDK packages are published from `main` using Changesets.
High-level flow:
1. Changesets are added for package changes.
2. CI validates formatting, linting, type checks, tests, and build.
3. Changesets action creates a release PR or publishes packages.
4. Tags are pushed after successful publish.
Primary workflow: [`.github/workflows/release.yml`](https://github.com/endalk200/better-webhook/blob/main/.github/workflows/release.yml)
The SDK workflow only watches SDK package directories. The CLI package is released separately and is ignored by Changesets.
## CLI releases
The `@better-webhook/cli` package is released from annotated git tags, not Changesets.
Tag format:
* Beta releases: `cli/v2.0.0-beta.2`
* Stable releases: `cli/v2.0.0`
High-level flow:
1. Merge the CLI release commit to `main`.
2. Create an annotated `cli/v*` tag from the merged commit.
3. Push the tag to trigger [`.github/workflows/cli-release.yml`](https://github.com/endalk200/better-webhook/blob/main/.github/workflows/cli-release.yml).
4. GoReleaser builds macOS, Linux, and Windows archives and publishes GitHub Release checksums.
5. Native npm platform packages publish first, then the `@better-webhook/cli` wrapper publishes.
Prerelease versions publish to the npm `beta` dist-tag and create GitHub prereleases. Stable versions publish to the npm `latest` dist-tag and create non-prerelease GitHub Releases.
Maintainers can run [`.github/workflows/cli-release-dry-run.yml`](https://github.com/endalk200/better-webhook/blob/main/.github/workflows/cli-release-dry-run.yml) manually to validate GoReleaser packaging and npm pack output without publishing.
## Versioning expectations
* Use semantic versioning for SDK packages.
* Breaking changes should be clearly indicated in release notes.
* Patch versions should remain backward compatible.
## Where to find release artifacts
* SDK packages: npm (`@better-webhook/*`)
* CLI package: npm (`@better-webhook/cli`)
* CLI direct downloads: GitHub Releases
# Security
import { Callout } from "fumadocs-ui/components/callout";
# Security
## Verify every webhook request
Use provider signature verification for all production endpoints.
Better Webhook verifies signatures automatically when secrets are configured.
Secret resolution order:
1. Adapter options
2. Provider options
3. Provider environment variables
Examples:
* `GITHUB_WEBHOOK_SECRET`
* `STRIPE_WEBHOOK_SECRET`
* `RAGIE_WEBHOOK_SECRET`
* `RECALL_WEBHOOK_SECRET`
* `RESEND_WEBHOOK_SECRET`
## Preserve raw request bodies
Signature verification depends on exact raw bytes. If middleware mutates the body before verification, checks may fail.
* Express: use `express.raw({ type: "application/json" })` for webhook routes.
* NestJS: ensure raw request body handling is configured before adapter invocation.
* GCP Cloud Functions: adapter reads raw body from platform request data.
See [Adapters](/docs/sdk/adapters) for framework details.
## Protect secrets and rotate when needed
* Store secrets in environment variables or your secret manager.
* Never commit secrets to git.
* Rotate secrets if leaked or if providers indicate suspicious activity.
* Keep separate secrets for dev/staging/production.
## Enable replay protection for idempotency-sensitive handlers
Use replay protection when duplicate delivery can cause side effects:
```ts
import { createInMemoryReplayStore } from "@better-webhook/core";
import { github } from "@better-webhook/github";
const replayStore = createInMemoryReplayStore();
const webhook = github().withReplayProtection({ store: replayStore });
```
Choose a durable store for production deployments that run across multiple instances.
## Handle verification failures explicitly
Use `.onVerificationFailed()` and `.onError()` to log and monitor security-relevant failures.
```ts
webhook
.onVerificationFailed((reason) => {
console.warn("Verification failed:", reason);
})
.onError((error, context) => {
console.error(`Webhook error in ${context.eventType}`, error);
});
```
Disabling signature verification in production is unsafe and not recommended.
# Troubleshooting
import { Callout } from "fumadocs-ui/components/callout";
# Troubleshooting
## Signature verification fails in SDK handlers
Most failures come from one of these:
* Wrong secret value (`_WEBHOOK_SECRET`, such as `GITHUB_WEBHOOK_SECRET`, or fallback `WEBHOOK_SECRET`).
* Body parser mutated the request body before verification.
* Provider is signing with a different secret than your environment.
For framework-specific raw-body requirements, see [Adapters](/docs/sdk/adapters).
## NestJS or Express handlers behave unexpectedly
* Express must use `express.raw({ type: "application/json" })` for webhook routes.
* NestJS should preserve raw body and handle `204` responses with `.end()` when no response body is returned.
Reference examples: [SDK Getting Started](/docs/sdk) and [Adapters](/docs/sdk/adapters).
## Secret-related errors
Set a provider-specific env var or generic fallback:
* `_WEBHOOK_SECRET` (for example `GITHUB_WEBHOOK_SECRET`)
* `WEBHOOK_SECRET`
# Adapter Options
import { Callout } from "fumadocs-ui/components/callout";
# Adapter Options
All adapters support the same core options.
| Option | Type | Description |
| -------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secret` | `string` | Secret used for signature verification. Overrides provider/env defaults. |
| `maxBodyBytes` | `number` | Optional body-size guard. Returns `413` when exceeded. |
| `onSuccess` | `(eventType: string) => void \| Promise` | Called after a successful `200` acknowledgement with `body.ok === true` (including ignored duplicates, excluding verified-but-unhandled `200` acknowledgements such as Resend). |
## Secret resolution order
When verifying signatures, secret lookup order is:
1. Adapter options
2. Provider options
3. Environment variables (`_WEBHOOK_SECRET`, then `WEBHOOK_SECRET` as a fallback; for example `GITHUB_WEBHOOK_SECRET` or `RESEND_WEBHOOK_SECRET`)
Keep framework and edge/proxy body limits configured in addition to `maxBodyBytes`.
Canonical reference: [Adapters](/docs/sdk/adapters#adapter-options)
# Framework Adapters
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";
# Framework Adapters
Adapters convert the webhook builder into framework-specific handlers. Each adapter handles the request/response lifecycle and passes the raw body for signature verification.
## Focused Guides
* [NestJS Adapter](/docs/sdk/nestjs-adapter)
* [GCP Functions Adapter](/docs/sdk/gcp-functions-adapter)
* [Adapter Options](/docs/sdk/adapter-options)
## Next.js
```bash
npm install @better-webhook/nextjs
```
```bash
pnpm add @better-webhook/nextjs
```
```bash
yarn add @better-webhook/nextjs
```
The Next.js adapter works with the App Router's route handlers.
### Basic Usage
```ts title="app/api/webhooks/github/route.ts"
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toNextJS } from "@better-webhook/nextjs";
const webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
export const POST = toNextJS(webhook);
```
### With Options
```ts
export const POST = toNextJS(webhook, {
// Override the secret (instead of using provider or env var)
secret: process.env.GITHUB_WEBHOOK_SECRET,
// Optional app-layer body size guard (bytes)
maxBodyBytes: 1024 * 1024, // 1MB
// Callback after successful processing
onSuccess: async (eventType) => {
console.log(`Successfully processed ${eventType}`);
},
});
```
### Response Behavior
| Status | Condition |
| ------ | ----------------------------------------------------------------- |
| `200` | Handler executed successfully |
| `204` | No handler registered for this event type (after verification) |
| `409` | Duplicate replay key detected (when replay protection is enabled) |
| `400` | Invalid JSON body or schema validation failed |
| `401` | Signature verification failed |
| `405` | Request method is not POST |
| `413` | Request body exceeds `maxBodyBytes` |
| `500` | Handler threw an error |
***
## Express
```bash
npm install @better-webhook/express
```
```bash
pnpm add @better-webhook/express
```
```bash
yarn add @better-webhook/express
```
The Express adapter returns a middleware function.
### Basic Usage
```ts title="src/webhooks.ts"
import express from "express";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toExpress } from "@better-webhook/express";
const app = express();
const webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
// Important: use express.raw() for this route
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
toExpress(webhook),
);
app.listen(3000);
```
**Important:** You must use `express.raw({ type: "application/json" })` before the webhook middleware. Without the raw body, signature verification will fail.
### With Options
```ts
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
toExpress(webhook, {
secret: process.env.GITHUB_WEBHOOK_SECRET,
onSuccess: async (eventType) => {
console.log(`Processed ${eventType}`);
},
}),
);
```
### Multiple Providers
```ts
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { ragie } from "@better-webhook/ragie";
import { document_status_updated } from "@better-webhook/ragie/events";
import { toExpress } from "@better-webhook/express";
const githubWebhook = github().event(push, async (payload) => {
// Handle GitHub
});
const ragieWebhook = ragie().event(document_status_updated, async (payload) => {
// Handle Ragie
});
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
toExpress(githubWebhook),
);
app.post(
"/webhooks/ragie",
express.raw({ type: "application/json" }),
toExpress(ragieWebhook),
);
```
***
## NestJS
```bash
npm install @better-webhook/nestjs
```
```bash
pnpm add @better-webhook/nestjs
```
```bash
yarn add @better-webhook/nestjs
```
The NestJS adapter returns an async function that processes the request and returns a result object.
### Basic Usage
```ts title="src/webhooks.controller.ts"
import { Controller, Post, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toNestJS } from "@better-webhook/nestjs";
@Controller("webhooks")
export class WebhooksController {
private webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
@Post("github")
async handleGitHub(@Req() req: Request, @Res() res: Response) {
const result = await toNestJS(this.webhook)(req);
if (result.body) {
return res.status(result.statusCode).json(result.body);
}
return res.status(result.statusCode).end();
}
}
```
### Raw Body Configuration
For signature verification to work, NestJS must preserve the raw request body. Enable this in your `main.ts`:
```ts title="src/main.ts"
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true, // Enable raw body
});
await app.listen(3000);
}
bootstrap();
```
Without `rawBody: true`, the adapter will attempt to re-serialize the parsed
body, which may not match the original and cause signature verification to
fail.
### With Options
```ts
@Post("github")
async handleGitHub(@Req() req: Request, @Res() res: Response) {
const handler = toNestJS(this.webhook, {
secret: process.env.GITHUB_WEBHOOK_SECRET,
onSuccess: async (eventType) => {
console.log(`Processed ${eventType}`);
},
});
const result = await handler(req);
if (result.body) {
return res.status(result.statusCode).json(result.body);
}
return res.status(result.statusCode).end();
}
```
### Result Object
The NestJS adapter returns a result object instead of directly sending a response:
```ts
interface NestJSResult {
statusCode: number;
body?: Record;
}
```
`body` is omitted for `204` responses, so use `.end()` when `result.body` is not present.
This gives you control over the response, allowing you to add headers, transform the body, or perform additional logic before responding.
***
## GCP Cloud Functions
```bash
npm install @better-webhook/gcp-functions
```
```bash
pnpm add @better-webhook/gcp-functions
```
```bash
yarn add @better-webhook/gcp-functions
```
The GCP Cloud Functions adapter works with both 1st and 2nd generation Cloud Functions.
### Basic Usage (2nd Gen)
```ts title="index.ts"
import { http } from "@google-cloud/functions-framework";
import { ragie } from "@better-webhook/ragie";
import { document_status_updated } from "@better-webhook/ragie/events";
import { toGCPFunction } from "@better-webhook/gcp-functions";
const webhook = ragie().event(document_status_updated, async (payload) => {
console.log(`Document ${payload.document_id} is now ${payload.status}`);
});
http("webhookHandler", toGCPFunction(webhook));
```
### Basic Usage (1st Gen)
```ts title="index.ts"
import { ragie } from "@better-webhook/ragie";
import { document_status_updated } from "@better-webhook/ragie/events";
import { toGCPFunction } from "@better-webhook/gcp-functions";
const webhook = ragie().event(document_status_updated, async (payload) => {
console.log(`Document ${payload.document_id} is now ${payload.status}`);
});
export const webhookHandler = toGCPFunction(webhook);
```
### With Options
```ts
http(
"webhookHandler",
toGCPFunction(webhook, {
secret: process.env.RAGIE_WEBHOOK_SECRET,
onSuccess: async (eventType) => {
console.log(`Processed ${eventType}`);
},
}),
);
```
### Raw Body for Signature Verification
GCP Cloud Functions with the Functions Framework provide `req.rawBody` automatically. The adapter checks for raw body in this order:
1. `req.rawBody` (Functions Framework default)
2. Buffer body
3. String body
4. `JSON.stringify(req.body)` as fallback
If using a custom setup without raw body preservation, signature verification
may fail due to JSON serialization differences.
### Deployment
Deploy using gcloud CLI:
```bash
gcloud functions deploy webhookHandler \
--gen2 \
--runtime nodejs20 \
--trigger-http \
--allow-unauthenticated \
--entry-point webhookHandler \
--set-env-vars RAGIE_WEBHOOK_SECRET=your-secret
```
***
## Hono
```bash
npm install @better-webhook/hono
```
```bash
pnpm add @better-webhook/hono
```
```bash
yarn add @better-webhook/hono
```
The Hono adapter returns a standard Hono handler and works across runtimes.
### Basic Usage
```ts title="src/webhooks.ts"
import { Hono } from "hono";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toHono } from "@better-webhook/hono";
const app = new Hono();
const webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
app.post("/webhooks/github", toHono(webhook));
export default app;
```
### Node.js
```ts title="src/index.ts"
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toHonoNode } from "@better-webhook/hono";
const app = new Hono();
const webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
app.post("/webhooks/github", toHonoNode(webhook));
serve(app);
```
### Cloudflare Workers
```ts title="src/worker.ts"
import { Hono } from "hono";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toHono } from "@better-webhook/hono";
const app = new Hono();
const webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
app.post("/webhooks/github", toHono(webhook));
export default app;
```
### Bun
```ts title="src/index.ts"
import { Hono } from "hono";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toHono } from "@better-webhook/hono";
const app = new Hono();
const webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
app.post("/webhooks/github", toHono(webhook));
export default {
port: 3000,
fetch: app.fetch,
};
```
### Deno
```ts title="src/main.ts"
import { Hono } from "hono";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toHono } from "@better-webhook/hono";
const app = new Hono();
const webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
app.post("/webhooks/github", toHono(webhook));
Deno.serve(app.fetch);
```
### Response Behavior
| Status | Condition |
| ------ | ----------------------------------------------------------------- |
| `200` | Handler executed successfully |
| `204` | No handler registered for this event type (after verification) |
| `409` | Duplicate replay key detected (when replay protection is enabled) |
| `400` | Invalid JSON body or schema validation failed |
| `401` | Signature verification failed |
| `405` | Request method is not POST |
| `413` | Request body exceeds `maxBodyBytes` |
| `500` | Handler threw an error |
**Raw Body:** Avoid consuming `c.req.raw` before the adapter runs. If you need
the body in middleware, use HonoRequest methods (e.g. `c.req.text()`), which
allow the adapter to reconstruct the body via `cloneRawRequest`.
With `app.post(...)`, non-POST requests may return `404` at the route layer
before the adapter runs. `405` is returned when the adapter itself receives a
non-POST request (for example, when mounted via `app.all(...)`).
***
## Adapter Options
All adapters accept the same options:
| Option | Type | Description |
| -------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secret` | `string` | Webhook secret for signature verification. Overrides provider secret and environment variables. |
| `maxBodyBytes` | `number` | Optional request body size guard in bytes. Returns `413` when exceeded. |
| `onSuccess` | `(eventType: string) => void \| Promise` | Callback invoked after a successful `200` acknowledgement with `body.ok === true` (including ignored duplicates, excluding verified-but-unhandled `200` acknowledgements such as Resend). Errors from this callback are ignored. |
If core replay protection is enabled on the webhook builder, adapters pass
through duplicate responses as `409` by default.
Use `maxBodyBytes` as an app-layer guard. Keep edge/proxy and framework body
limits configured as well for early rejection and better memory protection.
### Secret Resolution Order
When verifying signatures, the SDK looks for a secret in this order:
1. **Adapter options** — `toNextJS(webhook, { secret: "..." })`
2. **Provider options** — `github({ secret: "..." })`
3. **Environment variables** — `_WEBHOOK_SECRET`, then `WEBHOOK_SECRET` as a fallback (for example `GITHUB_WEBHOOK_SECRET` or `RESEND_WEBHOOK_SECRET`)
If the provider requires verification and no secret is found, the request is
rejected (typically `401`). Verification is skipped only when the provider is
explicitly configured with `verification: "disabled"`.
Always configure a secret in production. Without signature verification,
anyone can send fake webhooks to your endpoint.
### Adding Observability
Add observability at the builder level with `@better-webhook/otel` before passing the webhook to an adapter.
See the [OpenTelemetry](/docs/sdk/opentelemetry) guide for setup details.
# Custom Providers
import { Callout } from "fumadocs-ui/components/callout";
# Custom Providers
For webhook sources not covered by built-in providers, use `@better-webhook/core` to define events and create a custom provider.
## 1) Define event schemas and event definitions
```ts
import { defineEvent, z } from "@better-webhook/core";
const OrderSchema = z.object({
orderId: z.string(),
status: z.enum(["pending", "completed", "cancelled"]),
amount: z.number(),
});
export const orderCreated = defineEvent({
name: "order.created",
schema: OrderSchema,
provider: "my-ecommerce" as const,
});
export const orderUpdated = defineEvent({
name: "order.updated",
schema: OrderSchema,
provider: "my-ecommerce" as const,
});
```
## 2) Create a provider
```ts
import { createProvider, createHmacVerifier } from "@better-webhook/core";
export const myProvider = createProvider({
name: "my-ecommerce",
getEventType: (headers) => headers["x-event-type"],
getDeliveryId: (headers) => headers["x-delivery-id"],
verify: createHmacVerifier({
algorithm: "sha256",
signatureHeader: "x-signature",
signaturePrefix: "sha256=",
}),
});
```
## 3) Build a webhook and register handlers
```ts
import { createWebhook } from "@better-webhook/core";
import { myProvider, orderCreated, orderUpdated } from "./provider";
const webhook = createWebhook(myProvider)
.event(orderCreated, async (payload) => {
await handleOrderCreated(payload.orderId);
})
.event(orderUpdated, async (payload) => {
await handleOrderUpdated(payload.orderId, payload.status);
});
```
## 4) Attach to an adapter
```ts
import { toNextJS } from "@better-webhook/nextjs";
export const POST = toNextJS(webhook);
```
## Envelope payloads
If your provider wraps payloads in an envelope, extract the event and payload with `getEventType` and `getPayload`:
```ts
const envelopeProvider = createProvider({
name: "my-envelope-provider",
getEventType: (headers, body) => {
if (body && typeof body === "object" && "type" in body) {
return (body as { type: string }).type;
}
return undefined;
},
getPayload: (body) => {
if (body && typeof body === "object" && "payload" in body) {
return (body as { payload: unknown }).payload;
}
return body;
},
verify: createHmacVerifier({
algorithm: "sha256",
signatureHeader: "x-signature",
}),
});
```
`createHmacVerifier` covers common signature formats. If needed, provide your own `verify` implementation.
Canonical reference: [Providers](/docs/sdk/providers#custom-providers)
# GCP Functions Adapter
import { Callout } from "fumadocs-ui/components/callout";
# GCP Functions Adapter
## Install
```bash
npm install @better-webhook/gcp-functions
```
## Basic usage (Gen 2)
```ts
import { http } from "@google-cloud/functions-framework";
import { ragie } from "@better-webhook/ragie";
import { document_status_updated } from "@better-webhook/ragie/events";
import { toGCPFunction } from "@better-webhook/gcp-functions";
const webhook = ragie().event(document_status_updated, async (payload) => {
console.log(`Document ${payload.document_id} is now ${payload.status}`);
});
http("webhookHandler", toGCPFunction(webhook));
```
## Basic usage (Gen 1)
```ts
import { ragie } from "@better-webhook/ragie";
import { document_status_updated } from "@better-webhook/ragie/events";
import { toGCPFunction } from "@better-webhook/gcp-functions";
const webhook = ragie().event(document_status_updated, async (payload) => {
console.log(`Document ${payload.document_id} is now ${payload.status}`);
});
export const webhookHandler = toGCPFunction(webhook);
```
## Raw body behavior
Functions Framework usually provides `req.rawBody` automatically. The adapter checks, in order:
1. `req.rawBody`
2. Buffer body
3. String body
4. `JSON.stringify(req.body)` fallback
If your setup does not preserve raw body, signature verification can fail.
## Deployment example
```bash
gcloud functions deploy webhookHandler \
--gen2 \
--runtime nodejs20 \
--trigger-http \
--allow-unauthenticated \
--entry-point webhookHandler \
--set-env-vars RAGIE_WEBHOOK_SECRET=your-secret
```
Canonical reference: [Adapters](/docs/sdk/adapters#gcp-cloud-functions)
# SDK Getting Started
import { Steps, Step } from "fumadocs-ui/components/steps";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";
# SDK Getting Started
The Better Webhook SDK provides type-safe webhook handlers with automatic signature verification. It consists of:
* **Provider packages** — Type definitions and schemas for webhook sources (GitHub, Stripe, Ragie, Recall.ai, Resend)
* **Adapter packages** — Framework integrations (Next.js, Hono, Express, NestJS, GCP Cloud Functions)
* **Core package** — Base functionality for custom providers
## Installation
Install a provider and an adapter for your framework:
```bash
npm install @better-webhook/github @better-webhook/nextjs
```
```bash
pnpm add @better-webhook/github @better-webhook/nextjs
```
```bash
yarn add @better-webhook/github @better-webhook/nextjs
```
```bash
npm install @better-webhook/github @better-webhook/express
```
```bash
pnpm add @better-webhook/github @better-webhook/express
```
```bash
yarn add @better-webhook/github @better-webhook/express
```
```bash
npm install @better-webhook/github @better-webhook/nestjs
```
```bash
pnpm add @better-webhook/github @better-webhook/nestjs
```
```bash
yarn add @better-webhook/github @better-webhook/nestjs
```
```bash
npm install @better-webhook/ragie @better-webhook/gcp-functions
```
```bash
pnpm add @better-webhook/ragie @better-webhook/gcp-functions
```
```bash
yarn add @better-webhook/ragie @better-webhook/gcp-functions
```
```bash
npm install @better-webhook/github @better-webhook/hono
```
```bash
pnpm add @better-webhook/github @better-webhook/hono
```
```bash
yarn add @better-webhook/github @better-webhook/hono
```
If you also use builder-level observability, install `@better-webhook/otel` too.
## Quick Example
```ts title="app/api/webhooks/github/route.ts"
import { github } from "@better-webhook/github";
import { push, pull_request } from "@better-webhook/github/events";
import { toNextJS } from "@better-webhook/nextjs";
const webhook = github()
.event(push, async (payload) => {
// payload is fully typed!
console.log(`Push to ${payload.repository.name}`);
console.log(`${payload.commits.length} commits`);
})
.event(pull_request, async (payload) => {
if (payload.action === "opened") {
console.log(`New PR: ${payload.pull_request.title}`);
}
});
export const POST = toNextJS(webhook);
```
```ts title="src/webhooks.ts"
import express from "express";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toExpress } from "@better-webhook/express";
const app = express();
const webhook = github()
.event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
// Important: use express.raw() for signature verification
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
toExpress(webhook)
);
app.listen(3000);
```
```ts title="src/webhooks.controller.ts"
import { Controller, Post, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toNestJS } from "@better-webhook/nestjs";
@Controller("webhooks")
export class WebhooksController {
private webhook = github()
.event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
@Post("github")
async handleGitHub(@Req() req: Request, @Res() res: Response) {
const result = await toNestJS(this.webhook)(req);
if (result.body) {
return res.status(result.statusCode).json(result.body);
}
return res.status(result.statusCode).end();
}
}
```
```ts title="index.ts"
import { http } from "@google-cloud/functions-framework";
import { ragie } from "@better-webhook/ragie";
import { document_status_updated, connection_sync_finished } from "@better-webhook/ragie/events";
import { toGCPFunction } from "@better-webhook/gcp-functions";
const webhook = ragie()
.event(document_status_updated, async (payload) => {
// payload is fully typed!
console.log(`Document ${payload.document_id} is now ${payload.status}`);
})
.event(connection_sync_finished, async (payload) => {
console.log(`Sync ${payload.sync_id} completed`);
});
http("webhookHandler", toGCPFunction(webhook));
```
```ts title="src/webhooks.ts"
import { Hono } from "hono";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toHono } from "@better-webhook/hono";
const app = new Hono();
const webhook = github()
.event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
app.post("/webhooks/github", toHono(webhook));
export default app;
```
## Tree-Shaking
Events are exported separately from each provider's `/events` subpath, enabling bundlers to tree-shake unused events:
```ts
// Only the push schema is included in your bundle
import { push } from "@better-webhook/github/events";
// Multiple events - only these schemas are included
import { push, pull_request } from "@better-webhook/github/events";
```
This is especially beneficial for serverless deployments where bundle size matters.
## How It Works
### Create a Webhook Builder
Use a provider function (like `github()`) to create a webhook builder:
```ts
import { github } from "@better-webhook/github";
const webhook = github();
```
### Import Events
Import the specific events you want to handle from the `/events` subpath:
```ts
import { push, issues } from "@better-webhook/github/events";
```
### Register Event Handlers
Chain `.event()` calls to register handlers for specific event types:
```ts
const webhook = github()
.event(push, async (payload) => {
// Handle push events
})
.event(issues, async (payload) => {
// Handle issues events
});
```
Each handler receives a fully typed payload with autocomplete support.
### Convert to Framework Handler
Use an adapter to convert the webhook builder to your framework's handler format:
```ts
// Next.js
export const POST = toNextJS(webhook);
// Express
app.post("/webhooks/github", express.raw({ type: "application/json" }), toExpress(webhook));
// NestJS
const result = await toNestJS(this.webhook)(req);
// GCP Cloud Functions
http("webhookHandler", toGCPFunction(webhook));
// Hono
app.post("/webhooks/github", toHono(webhook));
```
## Signature Verification
Signature verification happens automatically when you provide a secret. The SDK looks for secrets in this order:
1. **Adapter options** — Pass `secret` to the adapter function
2. **Provider options** — Pass `secret` when creating the provider
3. **Environment variables** — Automatically checks `_WEBHOOK_SECRET`, then `WEBHOOK_SECRET` as a fallback (for example `GITHUB_WEBHOOK_SECRET`)
```ts
// Option 1: Adapter options
export const POST = toNextJS(webhook, {
secret: process.env.GITHUB_WEBHOOK_SECRET,
});
// Option 2: Provider options
const webhook = github({
secret: process.env.GITHUB_WEBHOOK_SECRET,
});
// Option 3: Environment variable (automatic)
// Just set GITHUB_WEBHOOK_SECRET in your environment
```
Verification is evaluated before unhandled-event routing. Requests for unknown
event types still need to pass signature verification before receiving an
acknowledgement status (`204` by default, `200` for providers such as Resend).
Always configure signature verification in production. Without it, anyone can
send fake webhooks to your endpoint.
## Replay Protection
Use core replay protection to enforce deduplication with a pluggable store:
```ts
import { createInMemoryReplayStore } from "@better-webhook/core";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
const replayStore = createInMemoryReplayStore();
const webhook = github()
.withReplayProtection({ store: replayStore })
.event(push, async (payload) => {
console.log(`Push to ${payload.repository.full_name}`);
});
```
Default duplicate behavior is `409`.
## Error Handling
Register error handlers to catch validation and handler errors:
```ts
import { push } from "@better-webhook/github/events";
const webhook = github()
.event(push, async (payload) => {
// Your handler
})
.onError((error, context) => {
console.error(`Error in ${context.eventType}:`, error);
// Log to monitoring service, etc.
})
.onVerificationFailed((reason, headers) => {
console.warn("Signature verification failed:", reason);
// Alert on potential attacks
});
```
## Observability
Add tracing and metrics with `@better-webhook/otel` at the builder level:
```ts
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { createOpenTelemetryInstrumentation } from "@better-webhook/otel";
import { toNextJS } from "@better-webhook/nextjs";
const webhook = github()
.instrument(
createOpenTelemetryInstrumentation({
includeEventTypeAttribute: true,
}),
)
.event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
export const POST = toNextJS(webhook);
```
Use builder-level instrumentation for all frameworks. Adapters do not accept observability plugins.
`@better-webhook/otel` wires the builder into the OpenTelemetry API. To actually export traces and metrics, your application still needs to register an OpenTelemetry SDK and exporters.
See [OpenTelemetry](/docs/sdk/opentelemetry) for configuration details and cardinality guidance.
## Next Steps
* [Providers](/docs/sdk/providers) — GitHub, Stripe, Ragie, Recall.ai, and Resend provider documentation with all supported events
* [Custom Providers](/docs/sdk/custom-providers) — Build provider integrations for any webhook source
* [Replay and Idempotency](/docs/sdk/replay-idempotency) — Prevent duplicate processing with replay protection
* [Adapters](/docs/sdk/adapters) — Framework-specific setup and configuration
* [NestJS Adapter](/docs/sdk/nestjs-adapter) — Raw body and response handling for NestJS
* [GCP Functions Adapter](/docs/sdk/gcp-functions-adapter) — Cloud Functions setup for 1st and 2nd gen
* [Adapter Options](/docs/sdk/adapter-options) — Shared options for secrets, limits, and callbacks
# NestJS Adapter
import { Callout } from "fumadocs-ui/components/callout";
# NestJS Adapter
## Install
```bash
npm install @better-webhook/nestjs
```
## Basic usage
```ts
import { Controller, Post, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { toNestJS } from "@better-webhook/nestjs";
@Controller("webhooks")
export class WebhooksController {
private webhook = github().event(push, async (payload) => {
console.log(`Push to ${payload.repository.name}`);
});
@Post("github")
async handleGitHub(@Req() req: Request, @Res() res: Response) {
const result = await toNestJS(this.webhook)(req);
if (result.body) {
return res.status(result.statusCode).json(result.body);
}
return res.status(result.statusCode).end();
}
}
```
## Preserve raw body
Enable raw body in `main.ts` for signature verification:
```ts
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
```
Without `rawBody: true`, verification can fail because re-serialized JSON may differ from the original signed payload.
## Result shape
The adapter returns a result object:
```ts
interface NestJSResult {
statusCode: number;
body?: Record;
}
```
If `body` is missing (for example `204`), end the response with `.end()`.
Canonical reference: [Adapters](/docs/sdk/adapters#nestjs)
# OpenTelemetry
# OpenTelemetry
Use `@better-webhook/otel` to emit traces and metrics from webhook processing.
Runnable example in this repo:
* `apps/examples/express-github-otel-example` for a complete Express plus OpenTelemetry runtime setup
## Install
```bash
pnpm add @better-webhook/otel @opentelemetry/api
```
`@better-webhook/otel` hooks into the builder-level instrumentation API exposed by `@better-webhook/core`. Add it to the webhook builder before passing the builder to any adapter.
## Builder Integration
```ts
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
import { createOpenTelemetryInstrumentation } from "@better-webhook/otel";
const webhook = github()
.instrument(
createOpenTelemetryInstrumentation({
includeEventTypeAttribute: false,
includeDeliveryIdAttribute: false,
includeReplayKeyAttribute: false,
}),
)
.event(push, async (payload) => {
console.log(payload.repository.full_name);
});
```
## Defaults
* Emits one processing span per webhook request
* Emits metrics for request count, completion count, duration, and failure paths
* Uses low-cardinality attributes by default
* Leaves `eventType`, `deliveryId`, and `replayKey` opt-in
* Emits span events for major lifecycle transitions by default
## Option Reference
| Option | Default | Description |
| ---------------------------- | ------- | ---------------------------------------------------------------------------------------------- |
| `emitMetrics` | `true` | Emit OTel counters and histograms |
| `emitSpanEvents` | `true` | Add span events for verification, replay, validation, handler, and completion lifecycle events |
| `includeEventTypeAttribute` | `false` | Add `better_webhook.event_type` to spans and metrics |
| `includeDeliveryIdAttribute` | `false` | Add `better_webhook.delivery_id` to spans |
| `includeReplayKeyAttribute` | `false` | Add `better_webhook.replay_key` to replay-related span events |
## Cardinality Guidance
* `eventType` is often acceptable if your provider emits a bounded set of events
* `deliveryId` is request-unique for many providers, so leave it off unless you explicitly need per-delivery trace correlation
* `replayKey` can also be highly unique, so leave it off unless debugging replay behavior
## What Gets Emitted
Spans:
* One span named `better-webhook.process` per request
* Provider and body-size attributes by default
* Completion attributes including HTTP status and success outcome
Metrics:
* `better_webhook.requests`
* `better_webhook.completed`
* `better_webhook.duration`
* `better_webhook.verification_failures`
* `better_webhook.schema_validation_failures`
* `better_webhook.handler_failures`
* `better_webhook.replay_duplicates`
* `better_webhook.body_too_large`
## Adapter Example
```ts
import express from "express";
import { toExpress } from "@better-webhook/express";
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
toExpress(webhook),
);
```
Observability is configured on the builder, not on adapter options.
## Runtime Setup
This package uses the OpenTelemetry API. Your application still needs to register an OpenTelemetry SDK and exporters if you want telemetry to be exported anywhere.
Register the SDK before your app starts handling requests. `@better-webhook/otel` also makes the webhook processing span active while your handler runs, so spans you create inside handlers will inherit from `better-webhook.process` when the SDK context manager is configured.
# Providers
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";
# Providers
Providers define the webhook source, including event types, payload schemas, and signature verification.
## Focused Guides
* [Custom Providers](/docs/sdk/custom-providers)
* [Replay and Idempotency](/docs/sdk/replay-idempotency)
## GitHub
```bash
npm install @better-webhook/github
```
```bash
pnpm add @better-webhook/github
```
```bash
yarn add @better-webhook/github
```
```ts
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
const webhook = github({ secret: process.env.GITHUB_WEBHOOK_SECRET }).event(
push,
async (payload) => {
console.log(`Push to ${payload.repository.full_name}`);
},
);
```
### Events
Import events from `@better-webhook/github/events`:
```ts
import { push, pull_request, issues, installation, installation_repositories } from "@better-webhook/github/events";
```
#### push
Triggered when commits are pushed to a repository branch or tag.
```ts
import { push } from "@better-webhook/github/events";
.event(push, async (payload) => {
console.log(payload.ref); // "refs/heads/main"
console.log(payload.repository.name); // "my-repo"
console.log(payload.commits.length); // Number of commits
console.log(payload.pusher.name); // Who pushed
for (const commit of payload.commits) {
console.log(commit.message);
console.log(commit.author.email);
}
})
```
**Key payload fields:**
* `ref` — Full git ref (e.g., `refs/heads/main`)
* `before` / `after` — Commit SHAs before and after push
* `commits` — Array of commit objects
* `repository` — Repository information
* `pusher` — User who pushed
#### pull\_request
Triggered when a pull request is opened, closed, merged, edited, etc.
```ts
import { pull_request } from "@better-webhook/github/events";
.event(pull_request, async (payload) => {
console.log(payload.action); // "opened", "closed", etc.
console.log(payload.number); // PR number
console.log(payload.pull_request.title); // PR title
console.log(payload.pull_request.state); // "open" or "closed"
console.log(payload.pull_request.merged_at); // Merge timestamp (if merged)
if (payload.action === "opened") {
// New PR opened
}
})
```
**Key payload fields:**
* `action` — What happened (`opened`, `closed`, `synchronize`, `labeled`, etc.)
* `number` — Pull request number
* `pull_request` — Full PR object with `title`, `body`, `state`, `head`, `base`
* `repository` — Repository information
* `sender` — User who triggered the event
#### issues
Triggered when an issue is opened, closed, edited, labeled, etc.
```ts
import { issues } from "@better-webhook/github/events";
.event(issues, async (payload) => {
console.log(payload.action); // "opened", "closed", etc.
console.log(payload.issue.number); // Issue number
console.log(payload.issue.title); // Issue title
console.log(payload.issue.state); // "open" or "closed"
console.log(payload.issue.labels); // Array of labels
})
```
**Key payload fields:**
* `action` — What happened (`opened`, `closed`, `edited`, `labeled`, etc.)
* `issue` — Full issue object with `number`, `title`, `body`, `state`, `labels`
* `repository` — Repository information
* `sender` — User who triggered the event
#### installation
Triggered when a GitHub App is installed, uninstalled, or has permissions changed.
```ts
import { installation } from "@better-webhook/github/events";
.event(installation, async (payload) => {
console.log(payload.action); // "created", "deleted", etc.
console.log(payload.installation.id); // Installation ID
console.log(payload.installation.account.login); // Account name
console.log(payload.repositories); // Repos (for "created" action)
})
```
**Key payload fields:**
* `action` — `created`, `deleted`, `suspend`, `unsuspend`, `new_permissions_accepted`
* `installation` — Installation details with `id`, `account`, `permissions`
* `repositories` — Array of accessible repos (only for `created` action)
#### installation\_repositories
Triggered when repositories are added to or removed from an installation.
```ts
import { installation_repositories } from "@better-webhook/github/events";
.event(installation_repositories, async (payload) => {
console.log(payload.action); // "added" or "removed"
console.log(payload.repositories_added); // Repos added
console.log(payload.repositories_removed); // Repos removed
})
```
### Signature Verification
GitHub uses HMAC-SHA256 signatures sent in the `X-Hub-Signature-256` header. The SDK verifies this automatically when a secret is configured.
***
## Stripe
```bash
npm install @better-webhook/stripe
```
```bash
pnpm add @better-webhook/stripe
```
```bash
yarn add @better-webhook/stripe
```
```ts
import { stripe } from "@better-webhook/stripe";
import {
charge_failed,
checkout_session_completed,
payment_intent_succeeded,
} from "@better-webhook/stripe/events";
const webhook = stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET })
.event(charge_failed, async (payload) => {
console.log(payload.data.object.failure_code);
})
.event(checkout_session_completed, async (payload) => {
console.log(payload.data.object.payment_status);
})
.event(payment_intent_succeeded, async (payload) => {
console.log(payload.data.object.amount);
});
```
### Events
Import events from `@better-webhook/stripe/events`:
```ts
import {
charge_failed,
checkout_session_completed,
payment_intent_succeeded,
} from "@better-webhook/stripe/events";
```
#### charge.failed
Triggered when a charge attempt fails.
**Key payload fields:**
* `data.object.id` — Stripe charge id
* `data.object.amount` / `currency` — Amount details
* `data.object.failure_code` / `failure_message` — Failure diagnostics
* `data.object.payment_intent` — Related payment intent id or expanded object
#### checkout.session.completed
Triggered when a Checkout Session completes successfully.
**Key payload fields:**
* `data.object.id` — Checkout session id
* `data.object.mode` — Session mode (`payment`, `subscription`, etc.)
* `data.object.payment_status` — Payment state (`paid`, `unpaid`, etc.)
* `data.object.amount_total` / `currency` — Checkout totals
* `data.object.customer` / `payment_intent` — Ids or expanded objects
#### payment\_intent.succeeded
Triggered when a PaymentIntent reaches `succeeded`.
**Key payload fields:**
* `data.object.id` — Payment intent id
* `data.object.status` — Final status (`succeeded`)
* `data.object.amount` / `currency` — Payment amount
* `data.object.latest_charge` — Charge id or expanded charge object
### Signature Verification
Stripe signatures are read from `Stripe-Signature`. The provider validates:
* `t=` freshness (300s tolerance by default, configurable)
* One or more `v1=` signatures (supports secret rotation)
* Signed payload format `${t}.${rawBody}` (requires raw request body access)
* Only `v1` signatures are used for verification (non-`v1` schemes are ignored)
Stripe replay dedupe uses `body.id` (the event id) as `replayKey`. Stripe
does not provide a standard delivery-id header, so `deliveryId` is undefined
for this provider.
***
## Ragie
```bash
npm install @better-webhook/ragie
```
```bash
pnpm add @better-webhook/ragie
```
```bash
yarn add @better-webhook/ragie
```
```ts
import { ragie } from "@better-webhook/ragie";
import { document_status_updated } from "@better-webhook/ragie/events";
const webhook = ragie({ secret: process.env.RAGIE_WEBHOOK_SECRET }).event(
document_status_updated,
async (payload) => {
console.log(`Document ${payload.document_id} is now ${payload.status}`);
},
);
```
Ragie webhooks use an envelope structure where the event type is in
`body.type` and the actual payload is in `body.payload`. Ragie includes a
required `body.nonce` for idempotency, and the SDK attaches it onto the
unwrapped payload as `payload.nonce` for convenience. Deduplication is not
enforced unless you enable replay protection in core or implement your own
dedupe storage.
### Events
Import events from `@better-webhook/ragie/events`:
```ts
import {
document_status_updated,
document_deleted,
entity_extracted,
connection_sync_started,
connection_sync_progress,
connection_sync_finished,
connection_limit_exceeded,
partition_limit_exceeded,
} from "@better-webhook/ragie/events";
```
All Ragie event payloads include `nonce` as a required idempotency key.
#### document\_status\_updated
Triggered when a document enters `indexed`, `keyword_indexed`, `ready`, or `failed` state.
```ts
import { document_status_updated } from "@better-webhook/ragie/events";
.event(document_status_updated, async (payload) => {
console.log(payload.document_id); // Document ID
console.log(payload.status); // "indexed", "ready", "failed", etc.
console.log(payload.name); // Document name
console.log(payload.external_id); // Your external ID (if provided)
console.log(payload.error); // Error message (if status is "failed")
})
```
**Key payload fields:**
* `document_id` — Unique document identifier
* `nonce` — Unique idempotency key for this webhook delivery
* `status` — `indexed`, `keyword_indexed`, `ready`, or `failed`
* `name` — Document name
* `partition` — Partition key
* `metadata` — User-defined metadata (nullable)
* `external_id` — Your external ID (nullable)
* `connection_id` — Connection ID if created via connection (nullable)
* `sync_id` — Sync ID if part of a sync (nullable)
* `error` — Error message if status is `failed` (nullable)
#### document\_deleted
Triggered when a document is deleted.
```ts
import { document_deleted } from "@better-webhook/ragie/events";
.event(document_deleted, async (payload) => {
console.log(payload.document_id);
console.log(payload.name);
console.log(payload.external_id);
})
```
**Key payload fields:**
* `document_id` — Unique document identifier
* `name` — Document name
* `partition` — Partition key
* `metadata` — User-defined metadata (nullable)
* `external_id` — Your external ID (nullable)
* `connection_id` — Connection ID (nullable)
* `sync_id` — Sync ID (nullable)
#### entity\_extracted
Triggered when entity extraction completes for a document.
```ts
import { entity_extracted } from "@better-webhook/ragie/events";
.event(entity_extracted, async (payload) => {
console.log(payload.entity_id); // Extracted entity ID
console.log(payload.document_id); // Source document ID
console.log(payload.document_name); // Source document name
console.log(payload.instruction_id); // Extraction instruction ID
console.log(payload.data); // Extracted entity data
})
```
**Key payload fields:**
* `entity_id` — Unique identifier for the extracted entity
* `document_id` — Source document ID
* `instruction_id` — Instruction ID used for extraction
* `document_name` — Source document name
* `document_external_id` — External ID of source document
* `document_metadata` — Metadata from source document
* `partition` — Partition key
* `sync_id` — Sync ID (nullable)
* `data` — The extracted entity data object
#### connection\_sync\_started
Triggered when a connection sync begins.
```ts
import { connection_sync_started } from "@better-webhook/ragie/events";
.event(connection_sync_started, async (payload) => {
console.log(payload.connection_id);
console.log(payload.sync_id);
console.log(payload.partition);
console.log(`Will create ${payload.create_count} documents`);
console.log(`Will delete ${payload.delete_count} documents`);
})
```
**Key payload fields:**
* `connection_id` — Connection identifier
* `sync_id` — Sync identifier
* `partition` — Partition key
* `create_count` — Number of documents to be created
* `update_content_count` — Number of documents with content updates
* `update_metadata_count` — Number of documents with metadata updates
* `delete_count` — Number of documents to be deleted
#### connection\_sync\_progress
Triggered periodically during a sync to report progress.
```ts
import { connection_sync_progress } from "@better-webhook/ragie/events";
.event(connection_sync_progress, async (payload) => {
console.log(`Created: ${payload.created_count}/${payload.create_count}`);
console.log(`Deleted: ${payload.deleted_count}/${payload.delete_count}`);
console.log(`Errors: ${payload.errored_count}`);
})
```
**Key payload fields:**
* `connection_id` — Connection identifier
* `sync_id` — Sync identifier
* `partition` — Partition key
* `create_count` / `created_count` — Total to create / created so far
* `update_content_count` / `updated_content_count` — Content updates total / completed
* `update_metadata_count` / `updated_metadata_count` — Metadata updates total / completed
* `delete_count` / `deleted_count` — Total to delete / deleted so far
* `errored_count` — Number of documents with errors
#### connection\_sync\_finished
Triggered when a connection sync completes.
```ts
import { connection_sync_finished } from "@better-webhook/ragie/events";
.event(connection_sync_finished, async (payload) => {
console.log(`Sync ${payload.sync_id} finished`);
console.log(`Connection: ${payload.connection_id}`);
})
```
**Key payload fields:**
* `connection_id` — Connection identifier
* `sync_id` — Sync identifier
* `partition` — Partition key
#### connection\_limit\_exceeded
Triggered when a connection exceeds its page limit.
```ts
import { connection_limit_exceeded } from "@better-webhook/ragie/events";
.event(connection_limit_exceeded, async (payload) => {
console.log(`Connection ${payload.connection_id} hit ${payload.limit_type} limit`);
})
```
**Key payload fields:**
* `connection_id` — Connection identifier
* `partition` — Partition key
* `limit_type` — Type of limit exceeded (e.g., `"page_limit"`)
#### partition\_limit\_exceeded
Triggered when a partition exceeds its document limit.
```ts
import { partition_limit_exceeded } from "@better-webhook/ragie/events";
.event(partition_limit_exceeded, async (payload) => {
console.log(`Partition ${payload.partition} hit limit`);
})
```
**Key payload fields:**
* `partition` — Partition key
* `limit_type` — Type of limit exceeded (if provided)
* `nonce` — Unique idempotency key for this webhook delivery
### Signature Verification
Ragie uses HMAC-SHA256 signatures sent in the `X-Signature` header.
***
## Recall.ai
```bash
npm install @better-webhook/recall
```
```bash
pnpm add @better-webhook/recall
```
```bash
yarn add @better-webhook/recall
```
```ts
import { recall } from "@better-webhook/recall";
import {
participant_events_join,
participant_events_chat_message,
transcript_data,
bot_done,
} from "@better-webhook/recall/events";
const webhook = recall({ secret: process.env.RECALL_WEBHOOK_SECRET })
.event(participant_events_join, async (payload) => {
const participantEvent = payload.data;
console.log(participantEvent.participant.name);
})
.event(participant_events_chat_message, async (payload) => {
const participantEvent = payload.data;
const message = participantEvent.data;
console.log(message.text);
})
.event(transcript_data, async (payload) => {
const transcript = payload.data;
console.log(transcript.words.length);
})
.event(bot_done, async (payload) => {
const botStatus = payload.data;
console.log(botStatus.code);
});
```
Recall events use an envelope where the SDK reads the event type from
`body.event` and passes the unwrapped `body.data` object to your handler as
`payload`.
Recall handler payloads still include nested provider fields such as
`payload.data.participant`, `payload.data.words`, and `payload.data.code`.
The SDK unwraps the outer envelope, not the inner Recall event schema.
### Events
Import events from `@better-webhook/recall/events`:
```ts
import {
participant_events_join,
participant_events_leave,
participant_events_update,
participant_events_speech_on,
participant_events_speech_off,
participant_events_webcam_on,
participant_events_webcam_off,
participant_events_screenshare_on,
participant_events_screenshare_off,
participant_events_chat_message,
transcript_data,
transcript_partial_data,
bot_joining_call,
bot_in_waiting_room,
bot_in_call_not_recording,
bot_recording_permission_allowed,
bot_recording_permission_denied,
bot_in_call_recording,
bot_call_ended,
bot_done,
bot_fatal,
bot_breakout_room_entered,
bot_breakout_room_left,
bot_breakout_room_opened,
bot_breakout_room_closed,
} from "@better-webhook/recall/events";
```
#### participant\_events.\*
Events:
* `participant_events.join`
* `participant_events.leave`
* `participant_events.update`
* `participant_events.speech_on`
* `participant_events.speech_off`
* `participant_events.webcam_on`
* `participant_events.webcam_off`
* `participant_events.screenshare_on`
* `participant_events.screenshare_off`
* `participant_events.chat_message`
```ts
import {
participant_events_join,
participant_events_chat_message,
} from "@better-webhook/recall/events";
.event(participant_events_join, async (payload) => {
console.log(payload.data.participant.id);
console.log(payload.data.timestamp.relative);
})
.event(participant_events_chat_message, async (payload) => {
console.log(payload.data.data.text);
console.log(payload.data.data.to);
})
```
**Key payload fields:**
* `data.participant` - Participant identity and metadata on the handler payload
* `data.timestamp` - Absolute and relative event timestamps
* `data.data` - Event-specific nested data such as chat content for `chat_message`
* `realtime_endpoint` / `participant_events` / `recording` / `bot` - Related Recall resources
#### transcript.\*
Events:
* `transcript.data`
* `transcript.partial_data`
```ts
import {
transcript_data,
transcript_partial_data,
} from "@better-webhook/recall/events";
.event(transcript_data, async (payload) => {
console.log(payload.data.words.map((word) => word.text).join(" "));
})
.event(transcript_partial_data, async (payload) => {
console.log(payload.data.words.length);
})
```
**Key payload fields:**
* `data.words` - Transcript word segments with relative timestamps on the handler payload
* `data.participant` - Speaker information
* `transcript` / `recording` / `bot` - Related Recall resources
#### bot.\*
Events:
* `bot.joining_call`
* `bot.in_waiting_room`
* `bot.in_call_not_recording`
* `bot.recording_permission_allowed`
* `bot.recording_permission_denied`
* `bot.in_call_recording`
* `bot.call_ended`
* `bot.done`
* `bot.fatal`
* `bot.breakout_room_entered`
* `bot.breakout_room_left`
* `bot.breakout_room_opened`
* `bot.breakout_room_closed`
```ts
import {
bot_joining_call,
bot_fatal,
} from "@better-webhook/recall/events";
.event(bot_joining_call, async (payload) => {
console.log(payload.data.code);
})
.event(bot_fatal, async (payload) => {
console.log(payload.data.sub_code);
})
```
**Key payload fields:**
* `data.code` - Machine-readable bot status code on the handler payload
* `data.sub_code` - Optional additional reason code
* `data.updated_at` - Status update timestamp
* `bot` - Bot resource metadata
### Signature Verification
Recall uses signature headers such as `webhook-id`, `webhook-timestamp`, and
`webhook-signature` (with `svix-*` compatibility for legacy flows). The SDK
verifies HMAC-SHA256 signatures automatically when `RECALL_WEBHOOK_SECRET` (or
`secret`) is provided, and rejects stale timestamps to reduce replay risk.
***
## Resend
```bash
npm install @better-webhook/resend
```
```bash
pnpm add @better-webhook/resend
```
```bash
yarn add @better-webhook/resend
```
```ts
import { resend } from "@better-webhook/resend";
import {
email_bounced,
email_delivered,
email_received,
} from "@better-webhook/resend/events";
const webhook = resend({ secret: process.env.RESEND_WEBHOOK_SECRET })
.event(email_delivered, async (payload) => {
console.log(payload.data.email_id);
})
.event(email_bounced, async (payload) => {
console.log(payload.data.bounce.type);
})
.event(email_received, async (payload) => {
console.log(payload.data.message_id);
});
```
Resend handlers receive the full webhook envelope: `{ type, created_at, data }`.
The provider reads the event type from `body.type` and validates the full
payload without unwrapping it.
### Events
Import events from `@better-webhook/resend/events`:
```ts
import {
email_sent,
email_scheduled,
email_delivered,
email_delivery_delayed,
email_complained,
email_bounced,
email_opened,
email_clicked,
email_received,
email_failed,
email_suppressed,
domain_created,
domain_updated,
domain_deleted,
contact_created,
contact_updated,
contact_deleted,
} from "@better-webhook/resend/events";
```
#### email.\*
Events:
* `email.sent`
* `email.scheduled`
* `email.delivered`
* `email.delivery_delayed`
* `email.complained`
* `email.bounced`
* `email.opened`
* `email.clicked`
* `email.received`
* `email.failed`
* `email.suppressed`
```ts
import { resend } from "@better-webhook/resend";
import {
email_delivered,
email_bounced,
email_clicked,
email_received,
} from "@better-webhook/resend/events";
const webhook = resend()
.event(email_delivered, async (payload) => {
console.log(payload.data.email_id);
console.log(payload.data.to);
})
.event(email_bounced, async (payload) => {
console.log(payload.data.bounce.message);
console.log(payload.data.bounce.subType);
})
.event(email_clicked, async (payload) => {
console.log(payload.data.click.link);
console.log(payload.data.click.userAgent);
})
.event(email_received, async (payload) => {
console.log(payload.data.message_id);
console.log(payload.data.attachments?.length ?? 0);
});
```
**Key payload fields:**
* `created_at` - Event timestamp for ordering and auditing
* `data.email_id` - Stable Resend email identifier
* `data.created_at` - Email object timestamp
* `data.from` / `data.to` / `data.subject` - Core email metadata (`data.subject` defaults to `""` when Resend omits it on `email.received`)
* `data.tags` - Tag payload from Resend as `Record`
* `data.bounce` / `data.click` / `data.failed` / `data.suppressed` - Event-specific detail objects when present
`email.received` is metadata-only. Fetch the full inbound body, headers, and
attachments through Resend's receiving APIs when you need the message content.
#### domain.\*
Events:
* `domain.created`
* `domain.updated`
* `domain.deleted`
```ts
import { resend } from "@better-webhook/resend";
import { domain_updated } from "@better-webhook/resend/events";
const webhook = resend().event(domain_updated, async (payload) => {
console.log(payload.data.name);
console.log(payload.data.status);
console.log(payload.data.records.length);
});
```
**Key payload fields:**
* `data.id` - Domain identifier
* `data.name` - Domain name
* `data.status` - Aggregated verification status
* `data.region` - Region where the domain is configured
* `data.records` - Verification record details
#### contact.\*
Events:
* `contact.created`
* `contact.updated`
* `contact.deleted`
```ts
import { resend } from "@better-webhook/resend";
import { contact_created } from "@better-webhook/resend/events";
const webhook = resend().event(contact_created, async (payload) => {
console.log(payload.data.email);
console.log(payload.data.segment_ids);
console.log(payload.data.unsubscribed);
});
```
**Key payload fields:**
* `data.id` - Contact identifier
* `data.audience_id` - Audience identifier
* `data.segment_ids` - Segment memberships; may be omitted on `contact.deleted`
* `data.email` / `data.first_name` / `data.last_name` - Contact profile data
* `data.unsubscribed` - Team-level unsubscribe state; may be omitted on `contact.deleted`
### Signature Verification
Resend uses Svix-compatible signature headers:
* `svix-id`
* `svix-timestamp`
* `svix-signature`
The SDK verifies the exact raw request body using HMAC-SHA256 over
`${id}.${timestamp}.${rawBody}` with the base64-decoded portion of the
`whsec_...` secret. By default the signed timestamp must be within `300`
seconds of the current time.
Verified but unhandled Resend requests are acknowledged with `200` to match
Resend's documented delivery contract, while still requiring signature
verification before acknowledgment.
***
## Replay and Idempotency
Core replay protection uses provider-specific replay keys:
* GitHub: `x-github-delivery`
* Stripe: `body.id` (event id). `deliveryId` is not set.
* Ragie: `body.nonce` (exposed as `payload.nonce`)
* Recall.ai: `webhook-id`/`svix-id`
* Resend: `svix-id`
When replay protection is enabled and a duplicate key is detected, the default
response is `409`.
```ts
import { createInMemoryReplayStore } from "@better-webhook/core";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
const webhook = github()
.withReplayProtection({
store: createInMemoryReplayStore(),
})
.event(push, async (payload) => {
await processPush(payload);
});
```
## Custom Providers
For webhook sources not covered by built-in providers, create a provider in
`@better-webhook/core` and then register events on a webhook builder.
### 1) Define event schemas and event definitions
```ts
import { defineEvent, z } from "@better-webhook/core";
const OrderSchema = z.object({
orderId: z.string(),
status: z.enum(["pending", "completed", "cancelled"]),
amount: z.number(),
});
// Define events for tree-shaking
export const orderCreated = defineEvent({
name: "order.created",
schema: OrderSchema,
provider: "my-ecommerce" as const,
});
export const orderUpdated = defineEvent({
name: "order.updated",
schema: OrderSchema,
provider: "my-ecommerce" as const,
});
```
### 2) Create a provider
```ts
import { createProvider, createHmacVerifier } from "@better-webhook/core";
export const myProvider = createProvider({
name: "my-ecommerce",
getEventType: (headers) => headers["x-event-type"],
getDeliveryId: (headers) => headers["x-delivery-id"],
verify: createHmacVerifier({
algorithm: "sha256",
signatureHeader: "x-signature",
signaturePrefix: "sha256=",
}),
});
```
### 3) Create a webhook builder and register handlers
```ts
import { createWebhook } from "@better-webhook/core";
import { myProvider, orderCreated, orderUpdated } from "./provider";
const webhook = createWebhook(myProvider)
.event(orderCreated, async (payload) => {
await handleOrderCreated(payload.orderId);
})
.event(orderUpdated, async (payload) => {
await handleOrderUpdated(payload.orderId, payload.status);
});
```
### 4) Attach to an adapter
```ts
import { toNextJS } from "@better-webhook/nextjs";
export const POST = toNextJS(webhook);
```
The `createHmacVerifier` helper supports common signature formats. For custom
verification logic, provide your own `verify` function.
### Envelope Payloads
Some webhook providers wrap the actual payload in an envelope structure. For example, a provider might send:
```json
{
"type": "order.created",
"payload": { "orderId": "123", "status": "pending", "amount": 99.99 },
"timestamp": "2024-01-01T00:00:00Z"
}
```
Use `getEventType` with the optional `body` parameter and `getPayload` to handle this:
```ts
const envelopeProvider = createProvider({
name: "my-envelope-provider",
// Extract event type from body instead of headers
getEventType: (headers, body) => {
if (body && typeof body === "object" && "type" in body) {
return (body as { type: string }).type;
}
return undefined;
},
// Extract the actual payload from the envelope
getPayload: (body) => {
if (body && typeof body === "object" && "payload" in body) {
return (body as { payload: unknown }).payload;
}
return body;
},
verify: createHmacVerifier({
algorithm: "sha256",
signatureHeader: "x-signature",
}),
});
```
With this configuration, your event handlers receive the unwrapped payload directly, and schema validation applies to the inner payload object.
# Replay and Idempotency
# Replay and Idempotency
Enable replay protection when duplicate webhook deliveries could create unwanted side effects.
Runnable examples in this repo:
* `apps/examples/express-github-inmemory-replay-example` for a process-local replay store
* `apps/examples/express-github-prisma-replay-example` for a durable Postgres-backed replay store implemented with Prisma
Core replay protection uses provider-specific replay keys:
* GitHub: `x-github-delivery`
* Stripe: `body.id` (event id)
* Ragie: `body.nonce` (exposed as `payload.nonce`)
* Recall.ai: `webhook-id` or `svix-id`
* Resend: `svix-id`
When replay protection is enabled and a duplicate is detected, the default response is `409`.
```ts
import { createInMemoryReplayStore } from "@better-webhook/core";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";
const webhook = github()
.withReplayProtection({
store: createInMemoryReplayStore(),
})
.event(push, async (payload) => {
await processPush(payload);
});
```
## Custom storage (Redis-style example)
You can provide your own durable replay store by implementing the `ReplayStore` contract.
This is recommended for multi-instance deployments where in-memory storage is insufficient.
```ts
import type { ReplayReserveResult, ReplayStore } from "@better-webhook/core";
interface RedisLikeClient {
set(
key: string,
value: string,
mode: "EX",
ttlSeconds: number,
condition: "NX",
): Promise<"OK" | null>;
expire(key: string, ttlSeconds: number): Promise;
del(key: string): Promise;
}
class RedisReplayStore implements ReplayStore {
constructor(private readonly redis: RedisLikeClient) {}
async reserve(
key: string,
inFlightTtlSeconds: number,
): Promise {
const result = await this.redis.set(
`replay:${key}`,
"in-flight",
"EX",
inFlightTtlSeconds,
"NX",
);
return result === "OK" ? "reserved" : "duplicate";
}
async commit(key: string, ttlSeconds: number): Promise {
await this.redis.expire(`replay:${key}`, ttlSeconds);
}
async release(key: string): Promise {
await this.redis.del(`replay:${key}`);
}
}
```
Use your custom store with replay protection:
```ts
const webhook = github()
.withReplayProtection({
store: new RedisReplayStore(redisClient),
policy: {
ttlSeconds: 24 * 60 * 60,
inFlightTtlSeconds: 60,
key: (context) => {
const base = context.replayKey ?? context.deliveryId;
return base ? `${context.provider}:${base}` : undefined;
},
onDuplicate: "conflict",
},
})
.event(push, async (payload) => {
await processPush(payload);
});
```
## Production guidance
* Use a shared durable store for multi-instance deployments.
* Keep replay windows aligned with provider retry behavior.
* Monitor duplicate volume to spot retries and abuse patterns.
Canonical reference: [Providers](/docs/sdk/providers#replay-and-idempotency)