RelayLink Webhooks on Cloudflare Workers

Verify RelayLink at the edge with Request.arrayBuffer(), Web Crypto HMAC verification, a Worker secret binding, and durable idempotent handoff.

4 min read

A Cloudflare Worker can authenticate RelayLink at the edge without parsing the JSON first. The runtime exposes the incoming body as an ArrayBuffer, provides Web Crypto, and injects encrypted secret bindings into the handler.

The important architectural choice is what happens after verification. An edge response is fast, but speed is not acceptance. Return 2xx only after the delivery id and pending work exist in durable storage.

Verify the ArrayBuffer with Web Crypto

Cloudflare documents request.arrayBuffer() in the Workers Request API. The body is single-use. Read it once, retain the bytes, and parse from that buffer only after the MAC succeeds.

const encoder = new TextEncoder();

function fromHex(hex) {
  return Uint8Array.from(hex.match(/../g), (pair) => parseInt(pair, 16));
}

export default {
  async fetch(request, env) {
    if (request.method !== "POST") return new Response(null, { status: 405 });

    const timestamp = request.headers.get("X-RelayLink-Timestamp");
    const signature = request.headers.get("X-RelayLink-Signature");
    const raw = new Uint8Array(await request.arrayBuffer());

    if (!/^[0-9]+$/.test(timestamp ?? "") ||
        !/^sha256=[0-9a-f]{64}$/.test(signature ?? "")) {
      return new Response(null, { status: 401 });
    }

    const prefix = encoder.encode(`${timestamp}.`);
    const signed = new Uint8Array(prefix.length + raw.length);
    signed.set(prefix);
    signed.set(raw, prefix.length);

    const key = await crypto.subtle.importKey(
      "raw",
      encoder.encode(env.RELAYLINK_WEBHOOK_SECRET),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["verify"]
    );
    const valid = await crypto.subtle.verify(
      "HMAC", key, fromHex(signature.slice(7)), signed
    );
    if (!valid) return new Response(null, { status: 401 });

    // Check freshness, validate JSON, and persist unique work before success.
    return new Response(null, { status: 202 });
  },
};

Cloudflare's official signed-request example specifically uses crypto.subtle.verify() to avoid an ordinary signature-string comparison. The RelayLink input differs from that example: it is the timestamp header, a literal period, and the exact body bytes.

After verification, parse the timestamp as Unix seconds and apply your own freshness policy. RelayLink does not prescribe a tolerance. Decode raw as strict UTF-8 JSON and require event and delivery_id to match the corresponding headers.

Bind the secret, do not deploy it as text

Create RELAYLINK_WEBHOOK_SECRET as a Workers secret. Cloudflare documents secrets as encrypted bindings whose values are unavailable in Wrangler or the dashboard after creation. Access it through env, as the example does.

Do not put the value in wrangler.jsonc, source, a plain variable, or a log statement. Do not log the incoming signature, full receiver URL, or body. A path can itself contain a credential.

Store the later MCP credential as a separate secret. It grants account access and has a different purpose from webhook verification.

Claim the delivery at the edge

RelayLink retries at least once after a non-2xx or timeout. The retry preserves X-RelayLink-Delivery and the exact body, while the attempt timestamp and signature may change.

Use the delivery id as a durable unique key. A Durable Object can serialize claims for a key; D1 can enforce a unique constraint; an existing database can hold an inbox and outbox. Store a body digest as well. If the same id and digest arrive again, return success without scheduling another fetch. If the digest differs, quarantine it.

A Queue can carry downstream work, but account for the gap between storing the receipt and sending the queue message. Prefer a recoverable pending inbox or an outbox committed with the receipt. Do not place the only enqueue call inside ctx.waitUntil() after returning 202: RelayLink would stop retrying before acceptance was durable.

Choose one component as the owner of processing state. For example, a Durable Object can record pending, return success, and retry its own queue handoff until it records queued. If D1 holds the inbox instead, make the worker query pending rows so a failed queue send remains discoverable. The goal is not a particular Cloudflare product; it is a receipt that always leads either to work or to a visible recoverable state.

Fetch outside the request

The package.received payload is a JSON envelope. It has package and thread ids, sender, topic, time, reply status, and mcp_url; it has no note, ask, briefing, or files.

A queue consumer authenticates separately to mcp_url, calls get_package, and performs the intended work. Sender and topic remain third-party strings. Keep them out of agent instructions and log templates.

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

Deploy the Worker on a public HTTPS route, then register that URL from RelayLink account settings. Registration is portal-only, and the secret is shown once; copy it directly into the binding. Send webhook.test. Verify that one changed byte returns non-2xx and that replaying a valid delivery does not create a second work item.

Frequently asked questions

Should a Worker use request.json() for signature verification?
No. Read request.arrayBuffer() first and verify those bytes. Parse JSON from that authenticated buffer afterward because a Request body can be consumed only once.
Does Cloudflare Workers have a constant-time HMAC verifier?
Yes. Cloudflare's signing example recommends crypto.subtle.verify() for HMAC rather than comparing generated signature strings.
Can ctx.waitUntil() hold the durable acceptance work?
Do not return 2xx before the delivery receipt and work item are durable. waitUntil can extend work after a response, but that response would already tell RelayLink the event was accepted.