A Next.js application can receive RelayLink without a separate API framework. Put a route.ts file under the App Router, force the Node.js runtime, and read the standard Request body before anything parses it.
The common failure is copying a JSON-style handler that starts with await request.json(). That discards the signed representation. RelayLink signs the precise UTF-8 body, including whitespace and escapes.
Create a Node.js Route Handler
Next.js documents that Route Handlers use Web Request and Response APIs and can receive POST webhooks without the Pages Router's body-parser configuration. Use app/api/relaylink/route.ts or another dedicated route:
import { createHmac, timingSafeEqual } from "node:crypto";
export const runtime = "nodejs";
export async function POST(request: Request) {
const timestamp = request.headers.get("x-relaylink-timestamp");
const signature = request.headers.get("x-relaylink-signature");
const secret = process.env.RELAYLINK_WEBHOOK_SECRET;
const raw = Buffer.from(await request.arrayBuffer());
if (!secret || !/^[0-9]+$/.test(timestamp ?? "") ||
!/^sha256=[0-9a-f]{64}$/.test(signature ?? "")) {
return new Response(null, { status: 401 });
}
const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
.update(Buffer.from(`${timestamp}.`, "utf8"))
.update(raw)
.digest();
const supplied = Buffer.from(signature.slice(7), "hex");
if (!timingSafeEqual(expected, supplied)) {
return new Response(null, { status: 401 });
}
// Enforce freshness, validate JSON, and durably enqueue.
return new Response(null, { status: 202 });
}
The format checks make both buffers 32 bytes before timingSafeEqual, which throws on unequal lengths. Do not compare the signature strings with ===.
The body can be consumed once. Parse it after verification with JSON.parse(raw.toString("utf8")), then require payload.event and payload.delivery_id to equal the event and delivery headers. Parse the signed timestamp as Unix seconds and apply the freshness policy chosen for your service; RelayLink does not invent one for receivers.
Keep the route dynamic and the secret server-only
POST Route Handlers run at request time and are not cached. The explicit runtime = "nodejs" keeps this example on the runtime that provides node:crypto. If you choose another runtime, replace the cryptography with its documented constant-time HMAC verification rather than assuming the import works.
Next.js loads non-public environment variables into server code. Never prefix this secret with NEXT_PUBLIC_, because that prefix makes a value eligible for browser bundling.
Your deployment host owns protected storage. On Vercel, use a sensitive environment variable, which becomes unreadable after creation. On another host, use its secret manager or runtime injection. Keep the MCP OAuth token or API key separate.
Do not log the environment value, signature, authorization header, full webhook URL, or raw body. Route logs should use the delivery id, event type, status, and duration.
Persist before the response
RelayLink accepts any 2xx and retries other outcomes. A response should mean the application has taken durable responsibility, not merely that JavaScript reached the end of the handler.
Use X-RelayLink-Delivery as a unique key in a database-backed inbox. Save a digest of raw and a pending work record in the same transaction or recoverable outbox. If another invocation races the insert, load the existing record. The same id and digest is a successful duplicate; the same id with another digest should not be processed.
Do not use a module-level Set, framework cache, or filesystem. Route Handlers can run on multiple instances, and serverless files and memory are not an idempotency boundary. Do not return 202 and then start an unawaited promise; the runtime may stop after the response.
Keep the callback short. An agent run or remote package fetch can exceed RelayLink's attempt timeout and cause a concurrent retry.
Fetch from a worker
The verified package.received JSON contains an envelope, not correspondence. It carries package_id, thread_id, sender, topic, time, reply status, and mcp_url; it carries no briefing, message, or files.
The pending worker authenticates separately to mcp_url and calls get_package. Receiving a valid HMAC does not grant that access. Sender and topic are third-party strings, so they are information for display or bounded routing, never instructions to an agent.
Returning 2xx for the envelope does not mark the package read. Only the worker's authenticated get_package call records that the recipient's assistant pulled it.
Deploy the route before adding its public HTTPS URL in RelayLink account settings. Registration is portal-only, and RelayLink reveals the signing secret once; send it straight to the host's secret store. Send webhook.test, then replay it to confirm the durable unique key prevents a second work item. Also alter one whitespace byte and confirm the handler refuses it; that proves no parser sits ahead of verification.