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:

shared-router.ts
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.

web-request.ts
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.

worker.ts
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.

hono.ts
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.

express.ts
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.

fastify.ts
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:

error-handling.ts
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;
}

Next steps