← Все статьи
Кейс

Учебная тревога из-за сбоя X OAuth: как маленькая команда держала DM в подписанной входящей очереди

1 июля статусная страница разработчиков X дала support-командам полезный сигнал: OAuth2.0 login и /2/users/me возвращали ошибки 401 с 23:00 UTC 30 июня до 01:00 UTC 1 июля. Инцидент был закрыт, а страница затем снова показывала, что все системы работают. Для команды, которая смотрит X раз в день, это фон. Для маленькой команды, чей support-процесс начинается с обновления OAuth, вызова /2/users/me и последующего опроса активности, это готовая учебная тревога.

История ниже о команде из трёх человек в Сингапуре. Во время запусков продукта они принимают вопросы клиентов из X, WhatsApp и LINE. Июльский инцидент не привёл к потере сообщений. Но postmortem показал проблему: их собственный pipeline ставил обновление идентичности X первым шагом каждой входящей задачи. Если этот первый шаг возвращает 401, очередь и маршрутизация даже не стартуют.

Решение было не в том, чтобы никогда не использовать официальный X API. Решение было в другом: перенести живой входящий поток клиентов в подписанную очередь, сохранять каждую доставку первой операцией, а API платформы использовать для ответа или обогащения данных, но не как ворота перед видимостью сообщений.

Хрупкий первый шаг

Первая X-интеграция команды была типичной для 2026 года. Она использовала X API v2 с OAuth 2.0 PKCE, проверяла аутентифицированного пользователя, а затем читала поверхности DM и упоминаний. В документации X по Direct Messages раздел Manage Direct Messages описан как endpoints для создания разговоров, отправки DM и удаления DM events от имени аутентифицированных пользователей. Требования тоже ясны: approved developer account, Project and App в Developer Console и user access tokens через OAuth 2.0 PKCE.

Эта официальная поверхность уместна, когда задача звучит как “отправить этот DM через X” или “управлять разговором через developer platform X”. Но операционная задача команды была другой:

Клиент пишет в X, WhatsApp или LINE
  -> support-система получает сообщение
  -> событие сохраняется до AI или ручной обработки
  -> агент отвечает с правильного аккаунта

Старый процесс ставил всё наоборот. Сначала он просил X подтвердить идентичность аккаунта, а уже потом помещал что-либо в support-очередь. В обычные дни этого никто не замечал. При инциденте OAuth или /2/users/me очередь не получала новую активность X, потому что intake-скрипт завершался до этапа маршрутизации.

Команде нужны были два свойства: сообщения должны приходить как события, а очередь не должна формироваться вокруг одного identity endpoint конкретной платформы.

Новый входящий контракт

Сначала они зарегистрировали webhook endpoint в UnifyPort:

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 предоставляет unofficial interface для обычных аккаунтов сообщений и доставляет входящую активность как единый стандартный event stream. Для X значение provider равно twitter; для WhatsApp и LINE используется тот же envelope. Входящий X DM выглядит так:

{
  "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. Support-очередь может маршрутизировать по значениям, а не изучать новую модель событий для каждого канала.

Проверить, сохранить, потом маршрутизировать

Каждая доставка содержит X-Device-Timestamp, а при включённой подписи ещё и X-Device-Signature. Подпись — это hex HMAC-SHA256 от timestamp, точки и raw request body, подписанный signing_secret endpoint. Команда поставила перед очередью такой проверяющий слой:

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();
});

Последовательность важнее самого кода. Сначала проверить подпись по raw bytes. Затем сохранить событие по id. И только потом отправлять его в Slack, helpdesk, CRM или AI triage. Документация UnifyPort прямо говорит: webhook events — единственная запись входящего трафика; пропущенные события нельзя позже восстановить из message-history API. Значит, сохранение — часть intake edge, а не улучшение где-то ниже.

Что произошло на следующей проверке

Через две недели команда провела внутреннюю проверку. Они заблокировали job, который раньше вызывал /2/users/me, оставили webhook receiver онлайн и отправили тестовые сообщения во все три канала.

Сообщения из X, WhatsApp и LINE попали в одну таблицу message.received. Slack-уведомления по X стали медленнее, потому что команда специально поставила на паузу worker, который подтягивал profile metadata. Но raw event уже был сохранён. Агенты видели, кто написал, когда пришло сообщение, какой аккаунт его получил и что сказал клиент. Специфичные для платформы данные могли догнаться позже.

Ответы остались явным действием. Когда агент решал ответить, backend вызывал 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 недоступен сам по себе, любая интеграция это почувствует. Разница уже и практичнее: support desk больше не зависит от успешного profile lookup или polling job до того, как сохранить клиентскую активность, уже доставленную на webhook.

Чеклист, который они оставили

Runbook перед каждым запуском стал коротким:

  1. Сначала зарегистрировать webhook с subscribed_events: ["message.received"].
  2. Держать подпись включённой и проверять X-Device-Signature по raw body.
  3. Сохранять каждое событие по id до маршрутизации, enrichment, AI или назначения человеку.
  4. Считать API платформы шагом обогащения или ответа, а не входными воротами.
  5. Маршрутизировать по provider, account_id и data.conversation.id, чтобы WhatsApp, LINE, Telegram, Zalo или TikTok не создавали отдельную очередь.

Июльский инцидент X был коротким. Поэтому он стал не катастрофой, а хорошим тестовым сигналом. Маленькие команды редко могут убрать все зависимости от API платформ. Но они могут выбрать, где эта зависимость стоит. Ставьте её после подписанной входящей очереди, а не перед тем, как клиентское сообщение попадёт к команде.