Agent skill
Relay Webhooks Skill
Receive and verify Relay (relayapp.im) webhooks, signed with Standard Webhooks. Use when setting up Relay agent webhook handlers, debugging webhook-signature verification, or handling events like message.received, message.sent, message.failed, reaction.added, participant.added, and chat.created.
Install this skill
npx skills add hookdeck/webhook-skills --skill relay-messenger-webhooks
Relay (relayapp.im) is a messenger where people chat with AI agents. An agent's webhook subscription receives message, reaction, participant, chat, contact, call, payment, location and rating events as a JSON POST signed with Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature. The envelope is {api_; the event name is the top-level event_type, and event_id is the deduplication key.
When to Use This Skill
- How do I receive Relay webhooks?
- How do I verify Relay webhook signatures (
webhook-signature,whsec_secret)? - How do I handle
message.received,message.sentormessage.failedevents? - How do I subscribe to
reaction.added,participant.addedorchat.created? - Why is my Relay webhook signature verification failing, or rejecting Hookdeck retries?
Verification (core)
Signed content is {webhook-id}.{webhook-timestamp}.{raw body}. Relay strips the whsec_ prefix, base64-decodes the rest as the HMAC key, signs with HMAC-SHA256, base64-encodes the result, and sends one or more space-separated v1,<signature> entries. Use a Standard Webhooks library (standardwebhooks on npm and PyPI), which compares in constant time. Read the webhook-* headers, and when webhook-id is absent fall back to the svix-* headers (svix-id, svix-timestamp, svix-signature), as Hookdeck does.
const { Webhook } = require('standardwebhooks');
const prefix = req.get('webhook-id') ? 'webhook' : 'svix';
const headers = {
'webhook-id': req.get(`${prefix}-id`),
'webhook-timestamp': req.get(`${prefix}-timestamp`),
'webhook-signature': req.get(`${prefix}-signature`),
};
// rawBody: the raw Buffer/string, never re-serialized JSON
const event = new Webhook(process.env.RELAY_MESSENGER_WEBHOOK_SECRET).verify(rawBody, headers);
// throws on a bad signature or a timestamp outside the window; returns the parsed body
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["RELAY_MESSENGER_WEBHOOK_SECRET"]) # "whsec_..."
event = wh.verify(raw_body, {"webhook-id": id_, "webhook-timestamp": ts, "webhook-signature": sig})
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
The secret is returned once as signing_secret when you create a webhook subscription (POST /v1/webhook-subscriptions), and it can't be read again. It starts with whsec_. Keep the full value in RELAY_. Handlers fail closed: with the variable unset they return 500 instead of accepting anything. A missing header or a bad signature returns 400. Relay treats a 401 (or any non-2xx/429/5xx status) from your receiver as terminal and stops retrying that event, so only reject deliveries that are really invalid. Relay's own TypeScript SDK also verifies with relay.webhooks.unwrap(rawBody, { headers }).
Hookdeck retries and replays hit the 5-minute window
The Standard Webhooks libraries always enforce a fixed 5-minute timestamp window, and it can't be configured. Hookdeck retries and replays resend the original webhook-timestamp, so the library rejects any retry or replay that happens more than 5 minutes after the original delivery. If you verify behind Hookdeck (or replay old events), use the manual verifier in references/verification.md, where the window can be dropped or widened. Deduplicate on event_id instead of relying on the timestamp. Relay's own retries reuse the event ID and body under a new webhook-timestamp (and so a new signature), which is why webhook-id is a stable event ID.
Common Event Types
27 events are documented (the list may not be exhaustive). The name is event_type in the JSON body, and only data varies by type. The examples handle these:
| Event | Triggered When |
|---|---|
message.received | A message from a person or another agent is committed for your agent |
message.sent | A message your agent sent is committed |
message.delivered | A message your agent sent reaches Delivered |
message.read | A message your agent sent reaches Read |
message.failed | A recipient agent's message.received webhook delivery went terminal |
reaction.added | A contact adds a reaction to a message part |
reaction.removed | A contact removes a reaction |
participant.added | A contact joins a group chat |
participant.removed | A contact leaves or is removed from a group chat |
chat.created | Relay creates a chat that includes your agent |
Relay also sends chat.group_name_updated, chat.group_icon_updated, chat.typing_/.stopped, contact.added/.removed, call.created/.updated/.ended, payment.succeeded/.canceled/.expired, location.sharing.started/.stopped and rating.created/.updated/.deleted. See references/overview.md for the envelope and delivery behavior.
Environment Variables
# signing_secret of the webhook subscription (Standard Webhooks format, starts with whsec_)
RELAY_MESSENGER_WEBHOOK_SECRET=whsec_xxxxx
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 relay-messenger --path /webhooks/relay-messenger
Create a webhook subscription whose target_url is the tunnel URL, then message your agent in Relay to see an event arrive. Relay only delivers to public HTTPS targets and treats redirects as terminal, so register the final URL.
Reference Materials
- references/overview.md - Relay webhook concepts, events, payload envelope, delivery and retries
- references/setup.md - Create a subscription through the API, store the secret, manage it
- references/verification.md - Standard Webhooks verification details, header fallback, manual verifier, timestamp window