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" }
}
}
關鍵欄位刻意保持單純:id、type、provider、account_id、occurred_at 和 data。支援佇列可以按值分流,而不需要為每個渠道學一套 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 收斂成五條:
- 先註冊 webhook,使用
subscribed_events: ["message.received"]。 - 保持簽名開啟,並用原始 body 驗證
X-Device-Signature。 - 每個事件先按
id存儲,再做分派、補充資料、AI 或人工指派。 - 把平台 API 呼叫視為補充資料或回覆步驟,不要當成入站閘門。
- 按
provider、account_id和data.conversation.id分流,這樣接入 WhatsApp、LINE、Telegram、Zalo 或 TikTok 時不需要再建另一條佇列。
7 月這次 X 事故很短,所以它更像測試信號,而不是災難。小團隊很難完全移除對平台 API 的依賴,但可以選擇依賴放在哪裡。把它放在簽名入站佇列後面,而不是放在客戶訊息到達團隊之前。