RelayLink Webhooks on AWS Lambda

Receive RelayLink through an API Gateway Lambda proxy integration, decode its body exactly once, verify HMAC, and claim delivery before returning success.

4 min read

An API Gateway endpoint in front of Lambda is a good RelayLink receiver only when it uses a proxy event without request mapping. The Lambda event is not the HTTP request itself: API Gateway places the body in a string and tells you whether that string is Base64.

AWS documents both forms in the HTTP API Lambda proxy event. It also documents that payload format 2.0 lowercases header names. Account for both details before parsing JSON.

Use the proxy event as the byte boundary

Choose an HTTP API route that accepts POST and passes payload format 2.0 directly to Lambda. Do not add a mapping template that parses or rebuilds the JSON. RelayLink needs a public HTTPS URL, so the route must be invokable without AWS credentials; the RelayLink HMAC is the request authentication.

Recover the body this way:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyRelayLink(event, secret) {
  const headers = event.headers ?? {};
  const timestamp = headers["x-relaylink-timestamp"];
  const supplied = headers["x-relaylink-signature"];
  if (!/^[0-9]+$/.test(timestamp ?? "") ||
      !/^sha256=[0-9a-f]{64}$/.test(supplied ?? "")) return false;

  const body = Buffer.from(
    event.body ?? "",
    event.isBase64Encoded ? "base64" : "utf8"
  );
  const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(Buffer.from(`${timestamp}.`, "utf8"))
    .update(body)
    .digest();
  const received = Buffer.from(supplied.slice(7), "hex");

  return timingSafeEqual(expected, received);
}

This code is deliberately limited to format and MAC verification. After it returns true, parse the timestamp as Unix seconds and enforce the freshness policy you chose for your deployment. RelayLink does not prescribe a tolerance. Then parse body, require event and delivery_id to match X-RelayLink-Event and X-RelayLink-Delivery, and reject malformed payloads.

Do not HMAC event.body when isBase64Encoded is true. That signs the transfer representation inside the Lambda event, not the decoded HTTP body. Conversely, do not Base64-decode an ordinary text event. AWS's payload encoding documentation explains why the flag and media configuration must be tested together.

Keep the secret out of deployment text

Put the one-time whsec_ value in AWS Secrets Manager. AWS's Lambda secrets guidance supports the Parameters and Secrets extension or an SDK call, with caching to avoid retrieving the same value on every warm invocation.

Grant only secretsmanager:GetSecretValue for that secret to the function role. Retrieve it through the extension, Powertools, or an SDK path appropriate to your runtime, and use a bounded cache so warm invocations do not fetch it repeatedly. Do not print the value, the signature, the full webhook URL, or the raw body to CloudWatch Logs.

The MCP credential used later is a different secret. Store it separately and grant it only to the worker that fetches packages.

Make acceptance durable

RelayLink retries failed or timed-out deliveries at least once. Each retry keeps the same delivery id and byte-identical body, but API Gateway invokes Lambda independently each time.

Use X-RelayLink-Delivery as a unique inbox key in DynamoDB or another durable store. Save a digest of the decoded body beside it. A conditional insert can distinguish the first request from a duplicate. A duplicate with the same digest returns 2xx without adding work; the same id with a different digest should be quarantined.

Avoid a fragile “write receipt, then send SQS” pair with no recovery between the operations. One option is to store a pending inbox item and have a DynamoDB Stream start processing. Another is a transactionally supported outbox in your existing store. Return 202 or another 2xx only after the durable item exists.

Do not keep the Lambda request open for an agent run. RelayLink's sender has a short attempt budget, and a timeout creates a retry even if your function continues.

Fetch in a separate worker

For package.received, the verified JSON is an envelope. It names package_id, thread_id, sender, topic, time, reply status, and mcp_url; it contains no briefing, message, or files.

The worker authenticates independently to mcp_url, calls get_package, and marks its inbox work complete. Sender and topic are third-party strings. They can inform routing or a user-facing summary, but they are never instructions to an agent.

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

Deploy the API first, then register its HTTPS route from RelayLink account settings. Registration is portal-only, and RelayLink shows the signing secret once; place it directly in Secrets Manager. Send webhook.test, inspect only ids and outcomes, and replay the same event to prove the conditional receipt prevents duplicate work.

Frequently asked questions

Why must the Lambda handler check isBase64Encoded?
API Gateway proxy events can represent the request body as text or Base64. Decode according to the event flag before calculating the HMAC; signing the Base64 wrapper would verify different bytes.
Where should the whsec_ value live on AWS?
Store it in AWS Secrets Manager and let the Lambda execution role retrieve it. AWS recommends Secrets Manager rather than ordinary environment variables for sensitive credentials.
Should Lambda call get_package before responding?
No. Verify and durably claim the delivery, return 2xx, then let a separate worker authenticate to mcp_url and fetch the package.