Webhook verification
Roundtrip sends each response to your webhook as a signed event. Verify the raw
request body with Roundtrip-Signature before you parse JSON, trust metadata,
or run workflow code.
The SDK can verify the same webhook in Web Request runtimes and Node frameworks:
import { webhooks } from "@roundtrip/sdk";
export const router = webhooks
.workflow({
name: "incident",
actions: {
decision: ["rollback", "wait"] as const,
},
})
.onAction("decision", "rollback", async ({ data }) => {
await enqueueRollback(data.metadata.incidentId);
})
.onAction("decision", "wait", async ({ data }) => {
await markDecisionDeferred(data.metadata.incidentId);
});Capture the raw body first
Do not call request.json() or run JSON body middleware before verification.
Use one of:
await request.text()in Web Request runtimes.await c.req.text()in Hono.express.raw({ type: "application/json" })in Express.- A route-scoped raw parser in Fastify.
Web Request
Use this pattern in TanStack Start server routes, Next.js Route Handlers,
Cloudflare Workers, and any runtime that gives you a standard Request.
export async function POST(request: Request) {
await router.handle({
rawBody: await request.text(),
headers: request.headers,
secret: process.env.ROUNDTRIP_WEBHOOK_SECRET!,
});
return new Response(null, { status: 204 });
}Cloudflare Workers
Workers already use standard Request and Response objects. Read the secret
from env and verify before doing any work.
import { router } from "./shared-router";
type Env = {
ROUNDTRIP_WEBHOOK_SECRET: string;
};
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
if (request.method !== "POST" || url.pathname !== "/roundtrip/webhooks") {
return new Response(null, { status: 404 });
}
await router.handle({
rawBody: await request.text(),
headers: request.headers,
secret: env.ROUNDTRIP_WEBHOOK_SECRET,
});
return new Response(null, { status: 204 });
},
} satisfies ExportedHandler<Env>;Workers caveat
Read the body once. If you need to log or queue work, do it after verification
or pass the verified event to ctx.waitUntil.
Hono
Hono exposes the raw body on c.req.text() and the standard headers on
c.req.raw.headers.
import { Hono } from "hono";
import { router } from "./shared-router";
type Bindings = {
ROUNDTRIP_WEBHOOK_SECRET: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post("/roundtrip/webhooks", async (c) => {
await router.handle({
rawBody: await c.req.text(),
headers: c.req.raw.headers,
secret: c.env.ROUNDTRIP_WEBHOOK_SECRET,
});
return c.body(null, 204);
});
export default app;Hono caveat
Do not put JSON validators or body-parsing middleware in front of the webhook route. Verify the raw body first, then dispatch.
Express
Use route-specific express.raw and register the webhook route before any
global express.json() middleware.
import express from "express";
import { webhooks } from "@roundtrip/sdk";
import { router } from "./shared-router";
const app = express();
app.post(
"/roundtrip/webhooks",
express.raw({ type: "application/json" }),
async (req, res, next) => {
try {
await router.handle({
rawBody: req.body,
headers: {
[webhooks.SIGNATURE_HEADER]: req.get(webhooks.SIGNATURE_HEADER),
},
secret: process.env.ROUNDTRIP_WEBHOOK_SECRET ?? "",
});
res.status(204).end();
} catch (err) {
next(err);
}
}
);
app.use(express.json());Express caveat
req.body must be the Buffer from express.raw. If express.json() runs
first, the exact signed bytes are gone.
Fastify
Scope a raw parser to the webhook plugin or route. The example below keeps the exact JSON string for verification, then parses it for the route handler.
import Fastify from "fastify";
import { webhooks } from "@roundtrip/sdk";
import { router } from "./shared-router";
declare module "fastify" {
interface FastifyRequest {
rawBody?: string;
}
}
const app = Fastify();
app.addContentTypeParser(
"application/json",
{ parseAs: "string" },
(request, body, done) => {
request.rawBody = body as string;
done(null, JSON.parse(body as string));
}
);
app.post("/roundtrip/webhooks", async (request, reply) => {
const signature = request.headers["roundtrip-signature"];
await router.handle({
rawBody: request.rawBody ?? "",
headers: {
[webhooks.SIGNATURE_HEADER]: Array.isArray(signature)
? signature[0]
: signature,
},
secret: process.env.ROUNDTRIP_WEBHOOK_SECRET ?? "",
});
return reply.code(204).send();
});Fastify caveat
That content-type parser affects JSON handling where it is registered. In a larger app, put it in a small plugin that only owns the webhook route.
Error responses
router.handle throws when the signature is missing, stale, or invalid. Return
401 for verification failures and let unexpected handler errors surface as
500s:
import { WebhookVerificationError } from "@roundtrip/sdk";
try {
await router.handle({ rawBody, headers, secret });
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response("Invalid signature", { status: 401 });
}
throw err;
}