Relay · Webhooks
Signed webhook delivery
Relay emits signed HTTP events after a conversation change commits. The host receives transport facts and decides whether they should produce email, mobile push, business workflow, or no further action.
Webhook delivery never gives Relay authority to call a host business API. The configured endpoint is a receiver owned by the host.
Current availability
The current self-hosted contract uses one deployment-wide endpoint, signing secret, timeout, and event allowlist.
| Setting | Current behavior |
|---|---|
| Endpoint | One URL for the Relay deployment |
| Signing secret | One shared secret for the Relay deployment |
| Default timeout | 5 seconds |
| Default event allowlist | message.created only |
| Success response | Any 2xx status |
Per-tenant endpoint registration, per-tenant signing secrets, and a customer-facing registration API are not implemented. A shared deployment that serves unrelated tenants should not present the current configuration as tenant-isolated webhook credentials.
Request contract
Each delivery is an HTTP POST with these headers:
Content-Type: application/json
X-Tonetify-Relay-Event: message.created
X-Tonetify-Relay-Signature: t=1784011200,v1=<hex_sha256> The signature is HMAC-SHA256 over this exact byte sequence:
<unix_timestamp>.<raw_request_body> Verify the raw body before parsing JSON. Re-serializing the payload can change its bytes and invalidate a correct signature. Reject malformed signatures, invalid digests, and timestamps outside a five-minute replay window.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyRelayWebhook(
rawBody: Buffer,
signatureHeader: string,
secret: string
) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((part) => part.trim().split("=", 2))
);
const timestamp = Number(parts.t);
const provided = parts.v1;
if (!Number.isInteger(timestamp) || !provided) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const received = Buffer.from(provided, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
} Event catalog
Only events in the deployment allowlist are emitted. The implementation currently supports:
| Event | Payload root | Meaning |
|---|---|---|
message.created | channel_id, message | A message was committed |
message.updated | channel_id, message | Message content was edited |
message.retracted | channel_id, message | The sender retracted a message |
message.deleted | channel_id, message | A message was deleted |
message.pinned | channel_id, message | A message was pinned |
message.unpinned | channel_id, message | A message was unpinned |
reaction.added | channel_id, reaction | A reaction was added |
reaction.removed | channel_id, reaction | A reaction was removed |
member.joined | channel_id, member | A channel membership was created |
member.left | channel_id, member | A channel membership was removed |
channel.closed | channel | A channel was closed |
cursor.updated | channel_id, cursor | A member advanced a read cursor |
notification.message | channel_id, recipient_user_id, message | A recipient may need a general notification |
notification.mention | channel_id, recipient_user_id, message | A recipient was mentioned |
Every payload also includes event and tenant_id.
Message lifecycle and notification events are suppressed for configured bot user IDs and members whose kind is agent, system, or line_bot. This prevents a Relay-managed agent response from feeding the same automation loop again.
Message event example
{
"event": "message.created",
"tenant_id": "tenant-uuid",
"channel_id": "channel-uuid",
"message": {
"id": "message-uuid",
"channel_id": "channel-uuid",
"sender_id": "user-uuid",
"body": "hello",
"nonce": "client-generated-uuid",
"seq": 42,
"parent_message_id": null,
"mentions": [],
"attachments": []
}
} Use message.seq to order messages inside a channel. Do not infer message order from webhook arrival time.
Attachment entries contain metadata, not storage credentials. Request a download URL through Relay’s attachment API when the host needs the file.
Notification intent
notification.message and notification.mention are recipient-specific intents. Relay does not send email, mobile push, or badges.
A general notification recipient is a member of the same channel who is not the sender, is not muted, and is not an agent, system, or LINE bot. If a recipient is mentioned, Relay emits notification.mention and suppresses notification.message for that same recipient.
{
"event": "notification.mention",
"tenant_id": "tenant-uuid",
"channel_id": "channel-uuid",
"recipient_user_id": "user-uuid",
"message": {
"id": "message-uuid",
"sender_id": "other-user-uuid",
"body": "Can you review this?",
"seq": 42
}
} The host still owns notification preference, quiet hours, channel routing, templates, and the final send.
Receiver behavior
Implement the receiver in this order:
- Read and retain the raw request body.
- Verify the signature and timestamp.
- Parse the JSON payload.
- Record or enqueue the event locally.
- Return a
2xxresponse quickly. - Apply host policy and side effects outside the request path.
Relay retries transport failures and non-2xx responses. Delivery is therefore at least once. A receiver must tolerate the same event arriving more than once and must not use arrival order as business truth.
The current payload has no stable delivery ID. Message events expose resource IDs and lifecycle timestamps, but some embedded events do not have enough identity for a universal deduplication key. Make downstream effects idempotent and retain the raw payload if exact duplicate detection matters.
Failure and replay boundary
Relay stores a post-commit payload snapshot before delivery and attempts a failed handoff up to five times. Exhausted deliveries enter an operator dead-letter queue with tenant scope, event, failure details, and the original body.
Dead-letter listing is an admin surface, not a tenant customer API. The current replay endpoint is not a complete public contract: message lifecycle replay rebuilds the payload from current message state, while embedded and notification events cannot be replayed correctly through that path. Until replay is repaired, operators should inspect the stored body and recover the host-side effect explicitly.
Product boundary
The signed delivery, event allowlist, payload snapshots, retries, and dead-letter capture are implemented. Per-tenant registration, per-tenant secrets, a stable delivery ID, and faithful replay for every event remain product work.
Treat this page as the receive contract for a self-hosted Relay deployment, not as a managed webhook control plane.