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-Timestampas Unix secondsX-RelayLink-Signatureassha256=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.