How to secure a RelayLink webhook receiver

Build the receiver as a narrow notification boundary: authenticate every POST, reject stale requests, deduplicate retries, fetch separately, and log no secrets.

4 min read

A public webhook endpoint accepts requests from the internet. Most requests reaching it are not entitled to wake an agent, fetch correspondence, or create work. The receiver should therefore do a small, strict job before it trusts anything: authenticate the bytes, reject stale requests, claim the delivery once, and hand off the fetch.

The webhook is a signal, not a package transport. Keeping that boundary sharp limits what a forged or duplicated POST can cause.

Authenticate the untouched request

RelayLink sends four security-relevant headers:

  • X-RelayLink-Event
  • X-RelayLink-Delivery
  • X-RelayLink-Timestamp
  • X-RelayLink-Signature

Read the raw request body before a framework parses, normalizes, or re-serializes it. Build the signed value as:

{timestamp}.{body}

Compute HMAC-SHA256 with the subscription's full whsec_ secret, encode the digest as lowercase hexadecimal, prefix it with sha256=, and compare it with X-RelayLink-Signature in constant time.

Do not accept an unsigned request because it came through an obscure URL. A token in the path can be an extra routing guard, but URLs appear in proxies, access logs, dashboards, and copied configuration. It must not replace the HMAC.

Each subscription has an independent secret. Select the expected secret from the endpoint or route configuration before verification; never try every customer secret until one matches.

Reject stale requests

X-RelayLink-Timestamp is Unix seconds and is part of the signed string. Check that it parses and falls within a freshness window appropriate for your queue and clock discipline.

The order matters:

  1. Read the timestamp and raw body.
  2. Verify the signature over both.
  3. Check freshness.
  4. Parse the authenticated body and require its event and delivery id to match the headers.
  5. Atomically claim the delivery id and queue durable downstream work.

Because the timestamp is signed, an attacker cannot take a captured body and attach a current timestamp without invalidating the signature. The freshness check then limits how long the captured signed request remains useful.

Keep server clocks synchronized. If you allow for clock skew, make the allowance explicit and monitor rejections near the boundary rather than silently widening it.

Deduplicate before causing side effects

RelayLink webhooks are delivered at least once. Retries keep the same X-RelayLink-Delivery value and the same body. Store that delivery id in a table or durable idempotency store with a uniqueness constraint.

Commit the idempotency record and work item together. A “seen” row committed before its work item can lose the event after a crash; a work item committed first can be duplicated. If another request loses the unique insert, return the same successful result without adding work again.

Do not deduplicate on sender, topic, package id alone, or arrival time. Separate subscriptions can receive envelopes for the same package, and a sender can use the same topic more than once. The delivery id names the delivery.

Return 2xx only after the claim and handoff are durable. RelayLink does not follow redirects, and failures are retried, so returning success before persistence can lose the event.

With the current defaults, one delivery receives up to six attempts over about an hour. The subscription is switched off only after a separate run of 30 consecutive failed attempts across deliveries; any 2xx resets that counter.

A package.received body contains an envelope: event, delivery id, package and thread ids, sender, topic, time, reply status, and mcp_url. It deliberately omits the note, TL;DR, ask, and briefing.

Your worker uses mcp_url and authenticates as the account to fetch the package. Keep that OAuth token or named API key separate from the webhook signing secret. The two credentials prove different things:

  • the webhook secret proves the POST came from RelayLink for this subscription;
  • the MCP credential proves the worker may act as the account.

Do not treat receipt of an envelope as evidence that a person read the package. The authenticated fetch is the event RelayLink can record honestly.

Keep operational data out of logs

Log a delivery id, event type, outcome, duration, and an internal receiver identifier. Avoid the full endpoint URL, because its path may contain a token. Never log the signing secret, signature, OAuth token, API key, authorization header, or fetched package body.

Sender names and topics are outside your control. Treat them as data, not log templates or instructions, and omit them unless an operational need justifies retaining them.

Finally, use the portal's test event after deployment. It exercises the public route and signature path without fetching correspondence, making it the safest way to verify this boundary before package traffic arrives.

Frequently asked questions

Is a secret token in the webhook URL enough?
No. A path token can reduce random traffic, but it is often copied into access logs and routing data. Always verify the RelayLink HMAC signature as the webhook authentication.
Should the webhook process the package before returning?
Usually no. Authenticate, check freshness, atomically record the delivery and queue the fetch, then return 2xx. Fetching and processing can happen outside the request deadline.
Can the webhook signing secret fetch the package?
No. It only verifies webhook POSTs. Fetch through mcp_url with a separate OAuth token or API key belonging to the RelayLink account.