Skip to main content
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"
{ "deleted": true }
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.