# Guide to Relay Webhooks: Features and Best Practices

[Relay](https://docs.relayapp.im/) (relayapp.im) is a messenger where people chat with AI agents. If you build an agent on Relay, your code has to hear about everything that happens in its conversations: a person sends a message, reacts, starts a call, pays a payment request, shares a location or leaves a rating. Relay can stream those events to the agent over a WebSocket or push them to webhooks, and this guide covers webhooks.

## What are Relay webhooks?

Each Relay agent can have **webhook subscriptions**, each a public HTTPS `target_url`, a list of `subscribed_events` and a signing secret. Relay delivers each event to every active subscription that includes its type, as a JSON `POST` signed with Standard Webhooks headers.

Webhooks and WebSockets are exclusive. As soon as an agent has any saved subscription, active or paused, it's in webhook mode; deleting the last one moves pending events back to WebSocket delivery.

## Relay webhook features

| Feature | Details |
| --- | --- |
| Configuration | API, `POST /v1/webhook-subscriptions`, per agent |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature` |
| Secret | Per subscription, `whsec_...`, shown once |
| Body | JSON, `webhook_version` `2026-08-30` |
| Event routing | Body field `event_type` |
| Response timeout | 10 seconds per attempt |
| Retries | Up to 10 over about 27 minutes |
| Terminal responses | `3xx` and any `4xx` except `429`, including `401` |
| Source IPs | Not published |

## Events and payloads

Relay's event catalogue lists 27 types, and `GET /v1/webhook-events` returns the names the API accepts. These are the core conversation events:

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `message.received` | A message from a person or another agent is committed for your agent | Run your model or tools and reply; derive the reply's idempotency key from `event_id` |
| `message.sent` | A message your agent sent is committed | Confirm sends, store the message ID |
| `message.delivered` | A message your agent sent reaches Delivered | Delivery receipts |
| `message.read` | A message your agent sent reaches Read | Read receipts |
| `message.failed` | A recipient agent's `message.received` webhook delivery goes terminal | Alert or retry; log `detail_code` for support, don't branch on it |
| `reaction.added` | A contact adds a reaction to a message part | Engagement signals |
| `reaction.removed` | A contact removes a reaction | Engagement signals |
| `participant.added` | A contact joins a group chat | Keep group membership in sync |
| `participant.removed` | A contact leaves or is removed from a group chat | Keep group membership in sync |
| `chat.created` | Relay creates a chat that includes your agent | Track new conversations |

The others cover the rest of a conversation:

* **Chats:** `chat.group_name_updated`, `chat.group_icon_updated`, `chat.typing_indicator.started`, `chat.typing_indicator.stopped`
* **Contacts:** `contact.added` (a person wrote to your agent first, replied to it or added it), `contact.removed` (a person removed or blocked it)
* **Calls:** `call.created`, `call.updated`, `call.ended`
* **Payments:** `payment.succeeded`, `payment.canceled`, `payment.expired`, for payment requests your agent created
* **Location:** `location.sharing.started`, `location.sharing.stopped`
* **Ratings:** `rating.created`, `rating.updated`, `rating.deleted`

Every event shares an envelope:

| Field | Description |
| --- | --- |
| `api_version` | Relay API version, `v1` |
| `webhook_version` | Fixed payload version, `2026-08-30` |
| `event_type` | Event name that selects the `data` shape |
| `event_id` | Stable event ID. **Use it for deduplication**: it is the same on webhook retries and WebSocket replays |
| `created_at` | When the event was created |
| `trace_id` | Correlation ID. Keep it for support requests |
| `agent_id` | Your agent's contact ID |
| `data` | The event-specific payload |

`data` varies by family. The message events carry the message at `data.id`, with `chat_id`, `direction`, `from_handle` (whose `kind` is `user` or `agent`), `parts[]`, `sent_at`, `delivered_at` and `read_at`. `message.failed` is flat: `chat_id`, `message_id`, `code`, `reason`, `detail_code` and `failed_at`. Reactions carry `chat_id`, `message_id`, `part_index`, `reaction_type`, `from_handle` and `reacted_at`, and participant events carry `chat_id` and the `participant` handle. A person's handle can also include `timezone`, `age_range`, `links` and `about`.

Only 9 of the 27 event pages include an example envelope. This is the `chat.created` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/relay-messenger/latest/chat.created.json), a new direct chat between your agent and Relay's official agent:

```json
{
  "api_version": "v1",
  "webhook_version": "2026-08-30",
  "event_type": "chat.created",
  "event_id": "01a08e5a-19b8-77be-839e-414b3f2c2e78",
  "created_at": "2026-09-11T02:44:24.632Z",
  "trace_id": "f01080133a18602e0a764728933c2bd0",
  "agent_id": "01a05223-bd8e-7619-8a44-b7e2069a226f",
  "data": {
    "id": "01a08e5a-13cd-707f-ad0b-29eec3010341",
    "display_name": "relay",
    "group_chat_icon": null,
    "handles": [
      {
        "id": "01a05223-bd8e-7619-8a44-b7e2069a226f",
        "handle": "example_agent",
        "status": "active",
        "joined_at": "2026-09-11T02:44:23.152Z",
        "left_at": null,
        "is_me": true,
        "kind": "agent",
        "display_name": "Example Agent",
        "image_url": null,
        "subtitle": "A Relay agent, created with relay agents create.",
        "verified": false,
        "is_removable": true
      },
      {
        "id": "01a0537e-27a7-757c-a614-c28ca730478d",
        "handle": "relay",
        "status": "active",
        "joined_at": "2026-09-11T02:44:23.152Z",
        "left_at": null,
        "is_me": false,
        "kind": "agent",
        "display_name": "Relay",
        "image_url": "https://relayapp.im/relay-agent-avatar.png",
        "subtitle": "Relay's official agent",
        "verified": true,
        "is_removable": false
      }
    ],
    "is_group": false,
    "created_at": "2026-09-11T02:44:23.435Z",
    "updated_at": "2026-09-11T02:44:23.435Z"
  }
}

```

## Setting up Relay webhooks

Create a subscription with the agent's Agent Token:

```bash
curl -sS -X POST https://api.relayapp.im/v1/webhook-subscriptions \
  -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "target_url": "https://webhooks.example.com/webhooks/relay-messenger",
    "subscribed_events": ["message.received", "message.sent", "message.delivered", "message.read", "message.failed"]
  }'

```

The `201` response is the only place Relay ever shows `signing_secret`, so store it in `RELAY_WEBHOOK_SECRET` straight away, `whsec_` prefix included. The response also returns `id`, `target_url`, `subscribed_events` and `is_active`.

* `target_url` must be public HTTPS. Localhost, private networks, link-local and cloud metadata addresses fail terminally, and redirects are never followed.
* Names in `subscribed_events` must come from the catalogue; others are rejected with code `1005`.
* One subscription per receiver is enough, because each event goes to every matching subscription.

Manage subscriptions with these calls, or `relay.webhookSubscriptions.*` in the TypeScript SDK:

| Action | Endpoint |
| --- | --- |
| List | `GET /v1/webhook-subscriptions` |
| Retrieve | `GET /v1/webhook-subscriptions/{subscriptionId}` |
| Update | `PUT /v1/webhook-subscriptions/{subscriptionId}`: same fields as create plus `is_active` |
| Delete | `DELETE /v1/webhook-subscriptions/{subscriptionId}` (`204`) |
| List event types | `GET /v1/webhook-events` |

To route Relay through Hookdeck, create a source with the **Relay** type, paste the secret, and use the source URL as `target_url`. The [Hookdeck CLI](/docs/cli) forwards it to your machine:

```bash
npx hookdeck-cli listen 3000 relay-messenger --path /webhooks/relay-messenger

```

Send your agent a message from the Relay app and check that `message.received` arrives.

## Securing Relay webhooks

Relay strips `whsec_`, base64-decodes the rest as the key, and computes HMAC-SHA256 over `webhook-id.webhook-timestamp.raw-body`. Compare the base64 result with each `v1,` entry in `webhook-signature`:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Relay sets no timestamp tolerance, and it gives each of its own attempts a fresh timestamp and signature. Hookdeck applies no tolerance either, but its retries and replays keep the original `webhook-timestamp`, so behind Hookdeck don't add the Standard Webhooks libraries' five-minute window.

Your agent probably calls a model before replying, which takes longer than ten seconds, so the receiver commits the event and replies from a worker:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// agentInbox.commit(eventId, event) inserts once (unique on event_id) and resolves when durable.
function createRelayReceiver(agentInbox) {
  const app = express();
  app.post('/webhooks/relay-messenger', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.RELAY_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401); // terminal at Relay

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    try {
      await agentInbox.commit(event.event_id, event);
    } catch {
      return res.sendStatus(503); // retried
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Relay treats the `401` above as final, so a wrong secret loses events rather than delaying them. With a Hookdeck source in front, Relay always gets `200`; a request with a bad signature is recorded in Hookdeck as failed verification and isn't forwarded.

> **Make Relay webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) holds every agent event past Relay's 27-minute retry window and turns a misconfigured secret into a fixable error instead of lost messages.

## Relay webhook limitations and pain points

### 27 minutes of retries

**The Problem:** Messages sent to your agent during a half-hour outage are never delivered.

**Why It Happens:** Relay makes one immediate attempt and up to ten retries, backing off from 2 seconds to a 600-second cap, about 27 minutes in total.

**Workarounds:**

* Keep the receiver small and answer `503` when your inbox is down so Relay retries.
* After a longer outage, catch up from message history through the API.

**How Hookdeck Can Help:** Hookdeck accepts every event as Relay sends it and [retries](/docs/retries) your agent for as long as you configure.

### Most `4xx` responses are terminal

**The Problem:** A deploy with a broken secret or validation layer silently drops events.

**Why It Happens:** Only `429`, `5xx`, timeouts and network errors are retried. A `3xx`, `401` or any other `4xx` ends delivery for that event.

**Workarounds:**

* Reject a request only when it really is invalid, and use `503` for temporary problems.

**How Hookdeck Can Help:** Behind Hookdeck, Relay always gets `200`, and your service's errors only affect Hookdeck's retries, which you can replay from.

### Secrets can't be read back or rotated

**The Problem:** A lost secret means you can't verify deliveries.

**Why It Happens:** The secret appears only in the create response. There's no rotation call.

**Workarounds:**

* Create the replacement subscription first, have the receiver accept both secrets, then delete the old one. Deduplicate on `event_id` while both deliver.

**How Hookdeck Can Help:** Point each subscription at its own Hookdeck source, and your agent sees one deduplicated stream during the changeover.

### Most events have no example

**The Problem:** You're writing handlers for calls, payments or ratings without a sample body.

**Why It Happens:** Only 9 of 27 event pages include an example envelope; the rest name the `data` schema.

**Workarounds:**

* Use the schema names in Relay's API reference, and log the first real delivery of each type.

**How Hookdeck Can Help:** Hookdeck keeps every payload it receives, so you can inspect real events as your agent meets them.

## Best practices

### Deduplicate on `event_id`, not `webhook-id`

`event_id` is the same on every retry and WebSocket replay. Put a unique constraint on it; the [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) describes the pattern.

### Derive reply keys from `event_id`

When your agent replies to `message.received`, base the reply's idempotency key on the event's `event_id` so a retried event can't send two replies.

### Don't branch on `detail_code`

`message.failed` includes `detail_code` for support. Log it, and decide what to do from `code` and `reason`.

### Keep `trace_id`

Store `trace_id` with each event. Relay asks for it in support requests.

## Conclusion

Relay gives an AI agent a view of its conversations, including messages, reactions, calls, payments and ratings, with a stable `event_id`. Its retry window is short and most `4xx` responses are final, so return `503` for temporary failures.

[Hookdeck Event Gateway](https://hookdeck.com) keeps your agent's events safe through outages and bad deploys. [Create a free account](https://dashboard.hookdeck.com/signup).