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 的依赖,但可以选择把依赖放在哪里。把它放在签名入站队列后面,而不是放在客户消息到达团队之前。