← 所有文章
案例分析

X OAuth 故障演練:小團隊如何把私訊放進簽名入站佇列

7 月 1 日,X 的開發者狀態頁給支援團隊一個很實用的提醒:OAuth2.0 login 和 /2/users/me 在 6 月 30 日 23:00 UTC 到 7 月 1 日 01:00 UTC 之間出現 401 錯誤。事故已經恢復,狀態頁後來顯示所有系統正常。對一天只看一次 X 的團隊來說,這只是背景噪音;但對支援流程從刷新 OAuth、呼叫 /2/users/me、再輪詢活動開始的小團隊來說,這就是一次故障演練。

這個案例來自一個 3 人新加坡出海團隊。產品發佈期間,他們同時從 X、WhatsApp 和 LINE 接收客戶問題。7 月這次事故沒有讓他們遺失訊息,但復盤指出一個問題:他們自己的支援流水線把 X 身分刷新放在每次入站任務的第一步。只要第一步回傳 401,後面的入隊和分派就不會開始。

解法不是「永遠不用 X 官方 API」。真正的改動是:把即時客戶訊息放進簽名入站佇列,先存下每次投遞,再把平台 API 放到回覆或補充資料階段,而不是讓它決定團隊能不能看到訊息。

脆弱的第一跳

團隊最早的 X 整合在 2026 年很常見:使用 X API v2 和 OAuth 2.0 PKCE,先確認目前授權使用者,再查詢私訊和提及。X 自己的 Direct Messages 文件把 Manage Direct Messages 描述為用於建立對話、傳送 DM、刪除 DM event 的 endpoint;前置條件也很清楚:已核准的 developer account、Developer Console 裡的 project 和 app,以及 OAuth 2.0 PKCE 產生的 user access token。

當任務是「透過 X 送出這則 DM」或「在 X 開發者平台管理對話」時,這個官方介面是合理的。但這個發佈團隊的營運任務不同:

客戶從 X、WhatsApp 或 LINE 傳來訊息
  -> 支援系統收到訊息
  -> 在任何 AI 或人工流程之前先存事件
  -> 坐席從正確帳號回覆

舊流程把順序倒過來了。它先要求 X 證明目前帳號身分,然後才把訊息放進支援佇列。平常沒有人注意;一旦 OAuth 或 /2/users/me 出問題,佇列就沒有新的 X 活動,因為入站腳本在分派之前就退出了。

團隊真正需要的是兩件事:訊息應該以事件形式到達;佇列不應該被某一個平台的身分 endpoint 定義。

新的入站約定

他們先註冊 UnifyPort webhook endpoint,再調整其他流程:

curl -X POST https://api.unifyport.ai/v1/webhook-endpoints \
  -H "X-Api-Key: <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://support.example.com/webhook",
  "status": "active",
  "subscribed_events": ["message.received"],
  "signing_secret": "<WEBHOOK_SIGNING_SECRET>"
}'

UnifyPort 的非官方接口連接一般訊息帳號,並把入站活動投遞成一條標準事件流。對 X 來說,provider 值是 twitter;對 WhatsApp 和 LINE,同樣的 envelope 仍然適用。一則 X 私訊會像這樣抵達:

{
  "id": "evt_9b71a4c20d",
  "type": "message.received",
  "provider": "twitter",
  "account_id": "acc_launch_x",
  "occurred_at": "2026-07-09T02:30:00Z",
  "data": {
    "conversation": { "id": "x_dm_48192", "type": "user", "title": "Aria Chen" },
    "sender": { "id": "x_user_48291", "type": "user", "name": "Aria Chen" },
    "message": {
      "id": "x_msg_20260709_001",
      "type": "text",
      "text": "The preorder link returns 401 for me. Can you check?",
      "direction": "inbound",
      "sent_at": "2026-07-09T02:29:58Z"
    },
    "event": { "kind": "message_received" }
  }
}

關鍵欄位刻意保持單純:idtypeprovideraccount_idoccurred_atdata。支援佇列可以按值分流,而不需要為每個渠道學一套 event model。

先驗證,再存儲,再分派

每次投遞都會帶上 X-Device-Timestamp;若啟用簽名,也會帶上 X-Device-Signature。簽名是用 endpoint 的 signing_secret 對 timestamp、英文句點和原始 request body 做 HMAC-SHA256 後得到的 hex 值。團隊把下面這層驗證放在佇列前面:

import crypto from "crypto";
import express from "express";

const app = express();
const signingSecret = process.env.WEBHOOK_SIGNING_SECRET;

app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const timestamp = req.get("X-Device-Timestamp") || "";
  const signature = req.get("X-Device-Signature") || "";

  const hmac = crypto.createHmac("sha256", signingSecret);
  hmac.update(timestamp + ".");
  hmac.update(req.body);
  const expected = hmac.digest("hex");

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!valid) {
    res.status(401).end();
    return;
  }

  const event = JSON.parse(req.body.toString("utf8"));
  await storeEvent(event.id, req.body);

  if (event.type === "message.received") {
    await routeInboundMessage({
      provider: event.provider,
      accountId: event.account_id,
      conversationId: event.data.conversation.id,
      senderId: event.data.sender.id,
      text: event.data.message.text,
      occurredAt: event.occurred_at
    });
  }

  res.status(200).end();
});

順序比程式碼本身更重要。先用原始位元組驗證簽名;再按 id 存事件;然後才送去 Slack、helpdesk、CRM 或 AI 分流工作。UnifyPort 文件明確說明,webhook event 是入站流量的唯一記錄;錯過的事件不能之後再從 message-history API 補回來。所以存儲是入站邊界的一部分,不是下游優化項。

下一次演練發生了什麼

兩週後,團隊做了一次內部演練。他們阻斷過去會呼叫 /2/users/me 的任務,保留 webhook receiver 在線,然後向三個渠道送測試訊息。

X、WhatsApp 和 LINE 訊息都進入同一張 message.received 表。因為團隊故意暫停了抓 profile metadata 的補充 worker,X 的 Slack 提醒慢了一點,但原始事件已經存好。坐席仍然能看到誰發了訊息、何時到達、哪個帳號收到,以及客戶說了什麼。平台側補充資料可以稍後再追上。

回覆仍然是明確動作。坐席決定回覆時,後端用已連接的帳號和收件人呼叫 POST /v1/messages

curl -X POST https://api.unifyport.ai/v1/messages \
  -H "X-Api-Key: <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
  "account_id": "acc_launch_x",
  "to": { "id": "x_user_48291", "type": "user" },
  "message": {
    "type": "text",
    "text": "Thanks for flagging it. The checkout link is fixed now."
  }
}'

這個設計不會讓平台故障消失。如果 X 本身不可用,任何整合都會受影響。差別更窄、也更實際:支援台不再依賴 profile lookup 或輪詢任務先成功,才能存下已經投遞到 webhook 的客戶活動。

團隊留下的檢查清單

他們把發佈前 runbook 收斂成五條:

  1. 先註冊 webhook,使用 subscribed_events: ["message.received"]
  2. 保持簽名開啟,並用原始 body 驗證 X-Device-Signature
  3. 每個事件先按 id 存儲,再做分派、補充資料、AI 或人工指派。
  4. 把平台 API 呼叫視為補充資料或回覆步驟,不要當成入站閘門。
  5. provideraccount_iddata.conversation.id 分流,這樣接入 WhatsApp、LINE、Telegram、Zalo 或 TikTok 時不需要再建另一條佇列。

7 月這次 X 事故很短,所以它更像測試信號,而不是災難。小團隊很難完全移除對平台 API 的依賴,但可以選擇依賴放在哪裡。把它放在簽名入站佇列後面,而不是放在客戶訊息到達團隊之前。