# 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)