Search the complete documentation

Try idempotency, proposal lifecycle, authentication, or ordering.

Navigate Open
繁體中文
Browse documentation

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.

SettingCurrent behavior
EndpointOne URL for the Relay deployment
Signing secretOne shared secret for the Relay deployment
Default timeout5 seconds
Default event allowlistmessage.created only
Success responseAny 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:

EventPayload rootMeaning
message.createdchannel_id, messageA message was committed
message.updatedchannel_id, messageMessage content was edited
message.retractedchannel_id, messageThe sender retracted a message
message.deletedchannel_id, messageA message was deleted
message.pinnedchannel_id, messageA message was pinned
message.unpinnedchannel_id, messageA message was unpinned
reaction.addedchannel_id, reactionA reaction was added
reaction.removedchannel_id, reactionA reaction was removed
member.joinedchannel_id, memberA channel membership was created
member.leftchannel_id, memberA channel membership was removed
channel.closedchannelA channel was closed
cursor.updatedchannel_id, cursorA member advanced a read cursor
notification.messagechannel_id, recipient_user_id, messageA recipient may need a general notification
notification.mentionchannel_id, recipient_user_id, messageA 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:

  1. Read and retain the raw request body.
  2. Verify the signature and timestamp.
  3. Parse the JSON payload.
  4. Record or enqueue the event locally.
  5. Return a 2xx response quickly.
  6. 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.

Next: handle limits, errors, and backpressure.