Cloud Run can host a RelayLink receiver as a container service or as a Functions Framework HTTP function. The function shape has one useful guarantee for Node.js: Google documents req.rawBody even though it also parses req.body from the content type.
Use that raw buffer for HMAC. The parsed object is convenient only after authentication.
Choose the HTTP function boundary
Google's Cloud Run functions guide says a Node.js HTTP function receives Express-style request and response objects, automatically parses the body, and exposes both req.body and req.rawBody.
const functions = require("@google-cloud/functions-framework");
const { createHmac, timingSafeEqual } = require("node:crypto");
functions.http("relaylinkWebhook", async (req, res) => {
const timestamp = req.get("X-RelayLink-Timestamp");
const signature = req.get("X-RelayLink-Signature");
const secret = process.env.RELAYLINK_WEBHOOK_SECRET;
const raw = req.rawBody;
if (!secret || !Buffer.isBuffer(raw) ||
!/^[0-9]+$/.test(timestamp ?? "") ||
!/^sha256=[0-9a-f]{64}$/.test(signature ?? "")) {
return res.sendStatus(401);
}
const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
.update(Buffer.from(`${timestamp}.`, "utf8"))
.update(raw)
.digest();
const supplied = Buffer.from(signature.slice(7), "hex");
if (!timingSafeEqual(expected, supplied)) return res.sendStatus(401);
// Check freshness, validate JSON, and persist unique work.
return res.sendStatus(202);
});
Do not compute the HMAC over JSON.stringify(req.body). RelayLink signs the exact JSON string it sent, including its original escaping and whitespace. The format checks ensure both MAC buffers are 32 bytes before the constant-time comparison.
After HMAC verification, parse the timestamp as Unix seconds and enforce your service's freshness policy. RelayLink does not prescribe a tolerance. Parse raw as UTF-8 JSON and require header event and delivery id to equal the body's event and delivery_id.
If you deploy a general container instead of a Cloud Run function, req.rawBody is not a Cloud Run container-contract feature. It comes from the Functions Framework. Configure your chosen HTTP framework to retain the original bytes and test it independently.
Make the route public but authenticated by HMAC
Cloud Run normally applies an Invoker IAM check. RelayLink cannot present a Google identity token, so this webhook route must permit unauthenticated invocation. Google's public access documentation describes disabling the Invoker check or granting the Invoker role to allUsers.
Public invocation means the internet can send requests; it does not mean those requests pass the handler. HMAC verification, timestamp freshness, event validation, and idempotency remain mandatory. Avoid placing unrelated routes in the same public service when they need Google IAM.
Cloud Run terminates TLS before proxying to the ingress container. The deployed service URL is HTTPS and is suitable for registration. Do not redirect the callback: RelayLink does not follow redirects.
Inject the secret from Secret Manager
Google recommends Secret Manager for sensitive Cloud Run configuration. The Cloud Run secrets guide supports a secret-backed environment variable or mounted file.
Grant the service identity access only to the required secret. If you use an environment variable, pin a secret version as Google recommends because it is resolved at instance startup. A mounted secret can support a different rotation approach; make the application's cache behavior explicit.
Never place whsec_ in the container image, deployment YAML as plain text, or logs. Keep the MCP credential in a separate secret and limit it to the component that fetches packages.
Handle concurrency with durable state
Cloud Run can run concurrent requests and multiple instances. An in-memory delivery-id set will race and disappears when an instance is replaced.
Put X-RelayLink-Delivery under a uniqueness guarantee in Firestore, Cloud SQL, or another durable inbox. Store a digest of req.rawBody and pending state. Commit a recoverable work item with that receipt, then return 202. The same id and digest is a successful duplicate; the same id with different bytes should be quarantined.
Do not return success and rely only on work continuing in the request process. RelayLink accepts any 2xx, so it stops transport retries at that point. Agent work and the MCP fetch belong in a separate worker driven by the pending record or a transactionally safe outbox.
Fetch the package later
package.received is a JSON envelope containing identifiers and metadata, not the briefing, message, or files. The worker uses its own OAuth token or API key at mcp_url and calls get_package.
Sender and topic are third-party text even after signature verification. Display or route them only as data; never append them to agent instructions.
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 public function, then register its HTTPS URL from RelayLink account settings. Registration is portal-only, and the signing secret is shown once; bind it directly from Secret Manager. Send webhook.test, then replay it and alter one body byte. One should deduplicate, and the altered body should fail verification before any durable work appears.