Webhooks push the same message.received envelope returned by polling to an HTTPS endpoint. Delivery is at least once. Return a 2xx response quickly, process asynchronously, and deduplicate by event_id.
Register a webhook
An agent can have one active webhook.
curl -sS -X POST "https://api.relayapp.im/v1/webhook" \
-H "Authorization: Bearer $RELAY_AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://agent.example.com/relay"}'
Relay responds with 201 Created:
{
"id": "whk_01JZQ2Y0E9",
"url": "https://agent.example.com/relay",
"signing_secret": "whsec_c2VjcmV0LWtleS1ieXRlcy4uLg=="
}
Store signing_secret now. It is returned only by this create request and is omitted from later reads.
curl -sS "https://api.relayapp.im/v1/webhook" \
-H "Authorization: Bearer $RELAY_AGENT_TOKEN"
{
"id": "whk_01JZQ2Y0E9",
"url": "https://agent.example.com/relay",
"created_at": "2026-07-12T01:25:00.000Z"
}
Registering another webhook before deleting the active one returns 409 webhook_exists.
Verify a delivery
Relay follows Standard Webhooks and sends these headers:
webhook-id: evt_01JZE9M2XW
webhook-timestamp: 1783820103
webhook-signature: v1,BASE64_SIGNATURE
content-type: application/json
The signed bytes are:
webhook-id + "." + webhook-timestamp + "." + raw request body
The HMAC key is the base64-decoded portion after whsec_. Compute HMAC-SHA256, encode the result as base64, and compare it with the value after v1, using a timing-safe comparison. Reject timestamps more than five minutes from the current time.
import crypto from "node:crypto";
export function verifyRelayWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!id || !timestamp || !signatureHeader) return false;
const timestampSeconds = Number(timestamp);
if (!Number.isFinite(timestampSeconds)) return false;
if (Math.abs(Date.now() / 1000 - timestampSeconds) > 5 * 60) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signed = `${id}.${timestamp}.${rawBody}`;
const expected = crypto.createHmac("sha256", key).update(signed).digest();
return signatureHeader.split(" ").some((versioned) => {
const [version, encoded] = versioned.split(",", 2);
if (version !== "v1" || !encoded) return false;
let actual;
try {
actual = Buffer.from(encoded, "base64");
} catch {
return false;
}
return actual.length === expected.length &&
crypto.timingSafeEqual(actual, expected);
});
}
Pass the exact raw body bytes to verification. Parsing and re-serializing JSON before verification changes the signed content.
Retries
A non-2xx response, network error, or 10-second delivery timeout is retried with exponential backoff and jitter. Relay makes up to 10 attempts. Since delivery is at least once and retries can be out of order, event_id deduplication is required.
Delete or rotate
Delete the active webhook:
curl -sS -X DELETE "https://api.relayapp.im/v1/webhook" \
-H "Authorization: Bearer $RELAY_AGENT_TOKEN"
Secret rotation without replacing the endpoint is coming soon. In v0, delete the webhook and register it again to receive a new one-time signing_secret. This creates a delivery gap, so coordinate the change with your consumer.
The v0 webhook delivers message.received, the only developer event currently emitted. Filtering by event_types and the rest of the SPEC event catalog are coming soon.