搜尋完整文件

輸入關鍵字,例如 idempotency、proposal lifecycle 或權限。

選擇 開啟
English
瀏覽文件

Relay · Webhooks

已簽章的 webhook delivery

Conversation change commit 後,Relay 會送出已簽章的 HTTP event。Host 接收 transport fact,再決定是否傳送 email、mobile push、啟動 business workflow,或不採取後續動作。

Webhook delivery 不會讓 Relay 取得呼叫 host business API 的權限。設定的 endpoint 是由 host 擁有的 receiver。

目前可用範圍

目前的 self-hosted contract 在整個 deployment 共用一個 endpoint、signing secret、timeout 與 event allowlist。

設定目前行為
Endpoint整個 Relay deployment 共用一個 URL
Signing secret整個 Relay deployment 共用一個 secret
Default timeout5 秒
Default event allowlist只有 message.created
Success response任何 2xx status

目前沒有 per-tenant endpoint registration、per-tenant signing secret,或 customer-facing registration API。服務不相關 tenant 的 shared deployment,不能把現有設定描述成具備 tenant-isolated webhook credential。

Request contract

每次 delivery 都是 HTTP POST,並帶有以下 header:

Content-Type: application/json
X-Tonetify-Relay-Event: message.created
X-Tonetify-Relay-Signature: t=1784011200,v1=<hex_sha256>

Signature 是對以下完整 byte sequence 計算 HMAC-SHA256:

<unix_timestamp>.<raw_request_body>

先使用 raw body 驗證,再 parse JSON。重新 serialize payload 可能改變 byte,使正確的 signature 驗證失敗。Malformed signature、invalid digest,以及超出五分鐘 replay window 的 timestamp 都應拒絕。

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

Relay 只送出 deployment allowlist 內的 event。目前 implementation 支援:

EventPayload root意義
message.createdchannel_idmessageMessage 已 commit
message.updatedchannel_idmessageMessage content 已編輯
message.retractedchannel_idmessageSender 已收回 message
message.deletedchannel_idmessageMessage 已刪除
message.pinnedchannel_idmessageMessage 已 pin
message.unpinnedchannel_idmessageMessage 已 unpin
reaction.addedchannel_idreactionReaction 已加入
reaction.removedchannel_idreactionReaction 已移除
member.joinedchannel_idmemberChannel membership 已建立
member.leftchannel_idmemberChannel membership 已移除
channel.closedchannelChannel 已關閉
cursor.updatedchannel_idcursorMember 已推進 read cursor
notification.messagechannel_idrecipient_user_idmessageRecipient 可能需要一般通知
notification.mentionchannel_idrecipient_user_idmessageRecipient 被 mention

每個 payload 也都包含 eventtenant_id

如果 sender 是設定的 bot user ID,或 member kind 為 agentsystemline_bot,Relay 不會產生 message lifecycle 與 notification event。這可避免 Relay 管理的 agent response 再次進入同一個 automation loop。

Message event 範例

{
  "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": []
  }
}

Channel 內的 message 應以 message.seq 排序,不能從 webhook arrival time 推斷 message order。

Attachment entry 只包含 metadata,不包含 storage credential。Host 需要下載檔案時,應透過 Relay attachment API 取得 download URL。

Notification intent

notification.messagenotification.mention 是 recipient-specific intent。Relay 不負責傳送 email、mobile push 或 badge。

一般 notification recipient 必須是同一 channel 的 member,且不是 sender、未 muted,也不是 agent、system 或 LINE bot。Recipient 被 mention 時,Relay 會送出 notification.mention,並抑制同一 recipient 的 notification.message

{
  "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
  }
}

Notification preference、quiet hours、channel routing、template 與最後的 send 都由 host 負責。

Receiver behavior

Receiver 應依以下順序處理:

  1. 讀取並保留 raw request body。
  2. 驗證 signature 與 timestamp。
  3. Parse JSON payload。
  4. 在 host 端記錄 event 或加入 queue。
  5. 儘快回傳 2xx response。
  6. 在 request path 之外套用 host policy 並執行 side effect。

Relay 會重試 transport failure 與非 2xx response,因此 delivery 是 at least once。Receiver 必須容許同一 event 重複送達,也不能把 arrival order 當成 business truth。

目前 payload 沒有 stable delivery ID。Message event 提供 resource ID 與 lifecycle timestamp,但部分 embedded event 沒有足以建立通用 deduplication key 的 identity。Downstream side effect 必須設計為 idempotent;需要精確辨識 duplicate 時,應保留 raw payload。

Failure 與 replay boundary

Relay 在 delivery 前先保存 post-commit payload snapshot,失敗的 handoff 最多嘗試五次。耗盡 retry 後,delivery 會進入 operator dead-letter queue,其中包含 tenant scope、event、failure detail 與 original body。

Dead-letter listing 是 admin surface,不是 tenant customer API。目前 replay endpoint 還不是完整的 public contract:message lifecycle replay 會從目前的 message state 重建 payload,embedded 與 notification event 則無法透過這條路徑正確 replay。在 replay 修復前,operator 應檢查 stored body,並明確復原 host-side effect。

Product boundary

Signed delivery、event allowlist、payload snapshot、retry 與 dead-letter capture 已實作。Per-tenant registration、per-tenant secret、stable delivery ID,以及所有 event 的 faithful replay 仍屬於 product work。

這份文件描述 self-hosted Relay deployment 的 receive contract,不代表已具備 managed webhook control plane。

下一步:處理限制、錯誤與 backpressure