Framework Adapters
Integrate Better Webhook with Next.js, Express, NestJS, GCP Cloud Functions, and Hono.
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
Next.js
npm install @better-webhook/nextjspnpm add @better-webhook/nextjsyarn add @better-webhook/nextjsThe Next.js adapter works with the App Router's route handlers.
Basic Usage
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
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
npm install @better-webhook/expresspnpm add @better-webhook/expressyarn add @better-webhook/expressThe Express adapter returns a middleware function.
Basic Usage
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
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
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
npm install @better-webhook/nestjspnpm add @better-webhook/nestjsyarn add @better-webhook/nestjsThe NestJS adapter returns an async function that processes the request and returns a result object.
Basic Usage
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:
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
@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:
interface NestJSResult {
statusCode: number;
body?: Record<string, unknown>;
}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
npm install @better-webhook/gcp-functionspnpm add @better-webhook/gcp-functionsyarn add @better-webhook/gcp-functionsThe GCP Cloud Functions adapter works with both 1st and 2nd generation Cloud Functions.
Basic Usage (2nd Gen)
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)
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
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:
req.rawBody(Functions Framework default)- Buffer body
- String body
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:
gcloud functions deploy webhookHandler \
--gen2 \
--runtime nodejs20 \
--trigger-http \
--allow-unauthenticated \
--entry-point webhookHandler \
--set-env-vars RAGIE_WEBHOOK_SECRET=your-secretHono
npm install @better-webhook/honopnpm add @better-webhook/honoyarn add @better-webhook/honoThe Hono adapter returns a standard Hono handler and works across runtimes.
Basic Usage
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
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
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
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
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<void> | 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:
- Adapter options —
toNextJS(webhook, { secret: "..." }) - Provider options —
github({ secret: "..." }) - Environment variables —
<PROVIDER>_WEBHOOK_SECRET, thenWEBHOOK_SECRETas a fallback (for exampleGITHUB_WEBHOOK_SECRETorRESEND_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 guide for setup details.