How to Verify RelayLink Webhook Signatures

Verify the exact raw body with HMAC-SHA256, compare the MAC in constant time, enforce your timestamp policy, and deduplicate the signed delivery id.

4 min read

Your endpoint received plausible JSON and a sha256= header. Parsing the body is not proof that RelayLink sent it. Verify the request before it can enqueue work or place any value in front of an agent.

The calculation is small, but three details decide whether it is correct: use the incoming timestamp text, preserve the exact body, and compare the MAC in constant time.

The exact signature recipe

RelayLink sends:

  • X-RelayLink-Timestamp as Unix seconds
  • X-RelayLink-Signature as sha256= followed by lowercase hexadecimal
  • A JSON body encoded as UTF-8
  • A subscription secret beginning with whsec_

On the sending side, the signed text is:

{timestamp}.{exact body string}

RelayLink encodes that whole string as UTF-8, encodes the secret as UTF-8, and computes HMAC-SHA256. The 32-byte MAC becomes 64 lowercase hexadecimal characters after sha256=.

Do not add spaces around the period. Do not substitute the timestamp from sent_at. The timestamp used here is the X-RelayLink-Timestamp header.

Language-neutral verification pseudocode

timestamp_text = REQUIRE_HEADER("X-RelayLink-Timestamp")
signature_text = REQUIRE_HEADER("X-RelayLink-Signature")
raw_body_bytes = READ_BODY_BYTES_WITHOUT_TRANSFORMING()

timestamp_value = PARSE_BASE10_INTEGER(timestamp_text)
REQUIRE(signature_text STARTS_WITH "sha256=")
provided_hex = REMOVE_PREFIX(signature_text, "sha256=")
REQUIRE(provided_hex IS EXACTLY 64 LOWERCASE_HEX_CHARACTERS)
provided_mac = HEX_DECODE(provided_hex)

signed_bytes = CONCAT(
    UTF8_ENCODE(timestamp_text + "."),
    raw_body_bytes
)
expected_mac = HMAC_SHA256(
    key = UTF8_ENCODE(subscription_secret),
    data = signed_bytes
)

REQUIRE(CONSTANT_TIME_EQUAL(expected_mac, provided_mac))
REQUIRE(timestamp_value IS WITHIN_YOUR_FRESHNESS_POLICY_OF NOW_UNIX_SECONDS)

event_name = REQUIRE_HEADER("X-RelayLink-Event")
delivery_id = REQUIRE_HEADER("X-RelayLink-Delivery")
payload = PARSE_JSON(UTF8_DECODE_STRICT(raw_body_bytes))
REQUIRE(payload.event EQUALS event_name)
REQUIRE(payload.delivery_id EQUALS delivery_id)

receipt = ATOMICALLY_INSERT_RECEIPT_AND_WORK(
    delivery_id,
    SHA256(raw_body_bytes),
    payload
)
IF receipt IS AN EXISTING_DELIVERY_WITH_THE_SAME_DIGEST:
    RETURN_2XX_WITHOUT_REPEATING_WORK()
REQUIRE(receipt IS NEW)

Concatenating the UTF-8 prefix with the raw body bytes is equivalent to the sender's UTF8(timestamp + "." + bodyText) calculation because the HTTP body is the UTF-8 encoding of that exact body text. It also avoids a decode-and-encode round trip before verification.

The names above describe operations, not a promise about any language library. Choose the cryptographic and constant-time primitives your platform documents.

Preserve the raw body

Many web frameworks parse JSON before application code sees it. Some also offer a convenience method that serializes the parsed object again. That reconstructed body is not suitable for signature verification.

These transformations all change signed bytes:

  • Reordering properties
  • Converting compact JSON to indented JSON
  • Normalizing number or date text
  • Changing escaped characters
  • Trimming a trailing newline

Capture raw body bytes at the HTTP boundary. If middleware consumes the stream, configure buffering or have that middleware retain the original bytes. Verify first; parse the same bytes after the MAC succeeds. Do not log the raw body, the signing secret, or a full receiver URL whose path may contain a token.

Use constant-time equality

An ordinary string comparison may stop at the first different character. Its running time can reveal how much of a secret-derived value matched. A constant-time comparison examines the full fixed-length MAC.

It is fine to reject a missing prefix, wrong length, or non-hex text before that comparison; those checks validate public formatting. Once you have two 32-byte MAC values, compare them with the platform's constant-time primitive. Do not write your own timing loop unless the platform gives you no reviewed option.

Timestamp freshness and replay

The timestamp is covered by the HMAC, so an attacker cannot replace an old timestamp with the current time while keeping the signature valid. After the MAC succeeds, compare the parsed Unix time with your clock and reject stale requests.

RelayLink does not mandate a tolerance. Pick one deliberately, account for expected clock skew and delivery delay, and monitor refusals. A sample value copied without regard to your environment is not a security policy.

Freshness does not replace deduplication. The same valid delivery can be retried within your accepted window. Verify every attempt before consulting the idempotency store, then store X-RelayLink-Delivery durably and make a repeated receipt with the same body return 2xx without repeating the work. Receipt and work must be committed atomically; the retry guide covers the mark-before and mark-after failure windows.

Finally, remember what the signature proves: possession of the subscription secret. It does not authorize access to package content. The worker must still authenticate independently to mcp_url before calling get_package.

Frequently asked questions

What exact value does RelayLink sign?
RelayLink signs the UTF-8 bytes of the timestamp header text, a literal period, and the exact JSON body string, using the UTF-8 subscription secret as the HMAC-SHA256 key.
How should I compare the signature?
Require the sha256= prefix and lowercase hexadecimal format, decode the supplied MAC, and compare it with the expected 32-byte MAC using a constant-time equality operation.
What timestamp tolerance does RelayLink require?
RelayLink does not prescribe a receiver tolerance. Define a freshness window for your own threat model and operations, then reject an otherwise valid signature whose Unix timestamp is stale.