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_version, webhook_version, event_type, event_id, created_at, trace_id, agent_id, data}; 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.sent or message.failed events?
  • How do I subscribe to reaction.added, participant.added or chat.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_MESSENGER_WEBHOOK_SECRET. 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:

EventTriggered When
message.receivedA message from a person or another agent is committed for your agent
message.sentA message your agent sent is committed
message.deliveredA message your agent sent reaches Delivered
message.readA message your agent sent reaches Read
message.failedA recipient agent's message.received webhook delivery went terminal
reaction.addedA contact adds a reaction to a message part
reaction.removedA contact removes a reaction
participant.addedA contact joins a group chat
participant.removedA contact leaves or is removed from a group chat
chat.createdRelay creates a chat that includes your agent

Relay also sends chat.group_name_updated, chat.group_icon_updated, chat.typing_indicator.started/.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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 13, 2026

View on GitHub →