RelayLink Webhooks on Azure Functions

Use an Azure Functions Node.js HTTP trigger to read one ArrayBuffer, verify RelayLink before JSON parsing, and persist an idempotent handoff.

4 min read

An Azure Function can verify RelayLink without a body-parser workaround. In the Node.js v4 model, the HTTP request follows the Fetch body model and exposes arrayBuffer(). The body is single-use, so the verification path must read it before any call to json() or text().

Microsoft's HttpRequest reference documents arrayBuffer(), headers, and bodyUsed. The Node.js Functions guide states that body methods can run only once.

Verify in the HTTP trigger

Register a POST-only function with authLevel: "anonymous". That makes the route publicly reachable; it does not make the request trusted. The RelayLink HMAC is the positive authentication check.

import { app } from "@azure/functions";
import { createHmac, timingSafeEqual } from "node:crypto";

app.http("relaylinkWebhook", {
  methods: ["POST"],
  authLevel: "anonymous",
  route: "hooks/relaylink",
  handler: async (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 { 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 { status: 401 };

    // Apply your timestamp policy, validate JSON, and durably enqueue here.
    return { status: 202 };
  },
});

The snippet leaves the persistence calls to your application, but the order is mandatory. After the MAC succeeds, parse the timestamp as Unix seconds and reject values outside your chosen freshness policy. RelayLink does not define that tolerance. Parse the same raw buffer as UTF-8 JSON, then require body event and delivery_id to equal X-RelayLink-Event and X-RelayLink-Delivery.

Do not call request.json() after arrayBuffer(); the body has already been consumed and a second body read is invalid. Parse with JSON.parse(raw.toString("utf8")).

Store the secret with a Key Vault reference

Azure Functions app settings are available through process.env. For the signing secret, make the setting value an Azure Key Vault reference, and grant the function app's managed identity access to that one secret.

The code sees the resolved value as RELAYLINK_WEBHOOK_SECRET without carrying vault credentials. Check startup or the first invocation for an unresolved reference string so a configuration error fails closed. Never log the environment value, incoming signature, raw body, or complete callback URL.

Keep the credential used for mcp_url in a separate Key Vault secret. The webhook secret verifies notification origin; it cannot read package content.

Persist before returning 202

RelayLink accepts any 2xx. The function should send one only after it has durably claimed the delivery and handed off work.

Use X-RelayLink-Delivery as a unique id in Cosmos DB, Azure SQL, or another persistent inbox. Store a SHA-256 digest of raw beside it. In the same reliable operation, create or expose a pending work record. A duplicate id with the same digest returns 204 or 202 without queuing again; a different digest under one id is a contract violation.

An output binding alone does not automatically make a separate database receipt and queue message atomic. If your design writes to two services, account for the crash between them. A single pending inbox that a worker polls, or an outbox in the same database, avoids acknowledging a receipt with no recoverable work.

Treat persistence timeouts as unsuccessful requests unless you can prove the receipt committed. Returning 202 on an uncertain write can lose the only retry that would reconcile it. If the database reports an unknown outcome, re-read by delivery id before choosing the response. That check is also useful when two scaled-out Function hosts race on the same retry.

Do not wait for package retrieval or an agent run. Function cold starts and downstream calls can outlast RelayLink's attempt budget, causing a retry while the first invocation is still active.

Fetch the envelope later

The queued package.received payload is a JSON envelope containing identifiers and metadata, not the briefing, message, or files. A queue-triggered function can authenticate separately to mcp_url and call get_package with package_id.

Treat sender.name and topic as third-party text even after HMAC verification. They can be displayed or used in carefully bounded routing; they must not become instructions.

Returning 2xx for the envelope does not mark the package read. Only the later authenticated get_package call records that the recipient's assistant pulled it.

Deploy the Function, then register its public HTTPS route from your RelayLink account. Registration is portal-only, and the signing secret is shown once; save it directly as the Key Vault value. Send webhook.test and confirm a changed byte fails, a stale signed request follows your policy, and a repeated delivery creates only one pending item.

Frequently asked questions

Should an Azure Function call request.json() first?
No. Read request.arrayBuffer() once, verify those bytes, and parse the verified buffer afterward. The Node.js Functions request body methods can only be consumed once.
Why is the HTTP trigger anonymous?
RelayLink must reach a public HTTPS endpoint and does not send an Azure function key. The HMAC signature authenticates the POST; Azure network controls can still add outer protection where compatible.
How should the signing secret be configured?
Use an app setting backed by an Azure Key Vault reference and read it from process.env. Do not put the whsec_ value in source, function.json, or logs.