Guide to Relay Webhooks: Features and Best Practices
Relay (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, a new direct chat between your agent and Relay's official agent:
{
"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:
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_urlmust be public HTTPS. Localhost, private networks, link-local and cloud metadata addresses fail terminally, and redirects are never followed.- Names in
subscribed_eventsmust come from the catalogue; others are rejected with code1005. - 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 forwards it to your machine:
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:
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:
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 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
503when 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 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
503for 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_idwhile 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 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 keeps your agent's events safe through outages and bad deploys. Create a free account.