RelayLink Webhooks with Express

Mount express.raw before JSON middleware, verify RelayLink against the Buffer with Node.js crypto, and commit an idempotent inbox record.

4 min read

An Express application can verify RelayLink with the Node.js standard library, but only if one route sees a Buffer before the application's usual JSON middleware. Middleware order is the contract.

Express documents that express.raw() populates req.body with a Buffer. It also documents that express.json() replaces the body with a parsed object. Once the JSON parser runs, serializing that object cannot reproduce the signed bytes reliably.

Mount a route-scoped raw parser first

Place the webhook route before app.use(express.json()), or put it on a router that has no earlier body parser:

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const secret = Buffer.from(process.env.RELAYLINK_WEBHOOK_SECRET ?? "", "utf8");

app.post(
  "/hooks/relaylink",
  express.raw({ type: "application/json", inflate: false }),
  async (req, res) => {
    const timestamp = req.get("X-RelayLink-Timestamp");
    const signature = req.get("X-RelayLink-Signature");

    if (!secret.length || !Buffer.isBuffer(req.body) ||
        !/^[0-9]+$/.test(timestamp ?? "") ||
        !/^sha256=[0-9a-f]{64}$/.test(signature ?? "")) {
      return res.sendStatus(401);
    }

    const expected = createHmac("sha256", secret)
      .update(Buffer.from(`${timestamp}.`, "utf8"))
      .update(req.body)
      .digest();
    const supplied = Buffer.from(signature.slice(7), "hex");

    if (!timingSafeEqual(expected, supplied)) return res.sendStatus(401);

    // Check freshness, validate JSON, and persist unique work.
    return res.sendStatus(202);
  }
);

app.use(express.json());

The format check ensures supplied has the same 32-byte length as the expected HMAC before timingSafeEqual. The signed value is the timestamp header text, one period, and the exact body bytes. Do not insert spaces or use the payload's sent_at.

The raw parser supports automatic inflation by default. This route disables it, so no compressed request is transformed before verification. RelayLink sends application/json without content encoding. If a reverse proxy in front of Express decompresses or rewrites bodies, configure it to pass this route unchanged and prove that behavior with a fixed signed fixture.

Parse only after authentication

After the HMAC succeeds, parse the timestamp as Unix seconds and apply your chosen freshness policy. RelayLink does not specify a tolerance. Then decode the Buffer as UTF-8 and call JSON.parse.

Require these pairs to match:

  • X-RelayLink-Event and payload.event
  • X-RelayLink-Delivery and payload.delivery_id

Validate the event-specific shape as well. webhook.test has no package id. package.received includes the package and thread ids, sender, topic, time, reply flag, and mcp_url.

Express warns that req.body is user-controlled. A valid signature authenticates the envelope, but sender name and topic are still another person's text. Do not use either as an agent instruction or an unescaped log template.

Supply secrets through the host

Express has no credential store. process.env is only the handoff from your runtime. Back RELAYLINK_WEBHOOK_SECRET with the secret manager offered by your container platform, process supervisor, or orchestrator. Fail startup when it is missing rather than accepting a route that can never verify.

Never commit the value to a .env file used in production. Do not print it, the incoming signature, full callback URL, raw body, or later MCP authorization header.

The MCP credential belongs to the worker, not necessarily the public Express process. Keep it separate and scope it to the RelayLink account.

Commit an inbox record before 2xx

RelayLink delivers at least once. A timeout or non-2xx causes the same delivery id and byte-identical body to be attempted again. Multiple Node processes can receive those attempts concurrently.

Insert X-RelayLink-Delivery into a durable inbox under a unique constraint. Store a SHA-256 digest of req.body and a pending work record in the same transaction or outbox. If a duplicate insert loses, compare digests and return 204 for the same body without repeating work. Refuse a different body under one delivery id.

An in-memory set is not sufficient across restarts or replicas. Neither is starting an unawaited promise and immediately sending 202. The response should follow the durable commit.

Fetch after the callback

The callback is a JSON envelope with identifiers and metadata, not correspondence or a file delivery. It contains no briefing, message, or files.

The pending worker authenticates separately to the payload's mcp_url and calls get_package with package_id. The webhook secret cannot perform that fetch.

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 public HTTPS route first. Register the final URL from RelayLink account settings; registration is portal-only, and the signing secret appears once, so place it directly in protected storage. Then send webhook.test. Verify a changed whitespace byte fails and a replay creates no second work item before allowing package events into the same path.

Frequently asked questions

Why does middleware order matter in Express?
If express.json runs first, req.body is already an object and the signed bytes are gone. Mount express.raw on the RelayLink route before any general JSON parser.
Should express.raw accept compressed requests?
RelayLink sends ordinary JSON. Setting inflate to false rejects compressed bodies instead of verifying bytes that middleware decompressed.
Does Express provide a secret vault?
No. Express is an HTTP framework. Inject the whsec_ value from the deployment platform's secret manager and keep it out of source and logs.