← 全記事
ケーススタディ

X OAuth 障害ドリル:小規模チームが DM を署名付きインバウンドキューに入れた方法

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 を使い、認証済みユーザーを確認してから DM とメンションの面を見に行く形です。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 の新しい活動がキューに入りません。

チームが必要としていたのは、メッセージがイベントとして届くこと、そしてキューが 1 つのプラットフォームの本人確認 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 の unofficial interface は通常のメッセージングアカウントを接続し、インバウンド活動を標準イベントストリームとして配送します。X の providertwitter です。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" }
  }
}

重要なフィールドは意図的にシンプルです。idtypeprovideraccount_idoccurred_atdata。サポートキューは値で分岐でき、チャネルごとに別の event model を覚える必要がありません。

検証、保存、それからルーティング

各配送には X-Device-Timestamp が付きます。署名を有効にしている場合は X-Device-Signature も付きます。署名は endpoint の signing_secret を使い、timestamp、ドット、raw 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();
});

大事なのはコードより順序です。raw bytes で署名を検証する。id でイベントを保存する。その後で Slack、ヘルプデスク、CRM、AI トリアージへ渡す。UnifyPort のドキュメントでは、webhook event がインバウンドトラフィックの唯一の記録であり、後から message-history API で取り戻すことはできないと説明されています。保存は後段の改善ではなく、取り込みエッジの一部です。

次のドリルで起きたこと

2 週間後、チームは内部ドリルを行いました。以前 /2/users/me を呼んでいたジョブを止め、webhook receiver は動かしたまま、3 つのチャネルにテストメッセージを送りました。

X、WhatsApp、LINE のメッセージは同じ message.received テーブルに入りました。X の Slack 通知だけは少し遅れました。プロフィール metadata を取る補足ワーカーを意図的に止めたからです。それでも raw event は保存済みでした。担当者は、誰が書いたか、いつ届いたか、どのアカウントが受け取ったか、顧客が何と言ったかを確認できました。プラットフォーム固有の補足情報は後から追いつけばよいのです。

返信は明示的な操作のままです。担当者が返答を決めたら、バックエンドは接続済みアカウントと宛先を指定して 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 自体が利用できなければ、どの連携も影響を受けます。違いはもっと実務的です。サポートデスクは、すでに webhook に届いた顧客活動を保存する前に、profile lookup や polling job の成功へ依存しなくなります。

残したチェックリスト

チームのローンチ前 runbook は短くなりました。

  1. まず webhook を登録し、subscribed_events: ["message.received"] を使う。
  2. 署名を有効にし、raw body で X-Device-Signature を検証する。
  3. ルーティング、補足情報、AI、人間への割り当てより先に、すべてのイベントを id で保存する。
  4. プラットフォーム API 呼び出しは補足情報や返信のステップに置き、取り込みの門番にしない。
  5. provideraccount_iddata.conversation.id でルーティングし、WhatsApp、LINE、Telegram、Zalo、TikTok を追加しても別キューを作らない。

7 月の X インシデントは短いものでした。だからこそ災害ではなく、良いテスト信号になりました。小規模チームがプラットフォーム API への依存をすべて消すのは現実的ではありません。しかし依存をどこに置くかは選べます。顧客メッセージがチームに届く前ではなく、署名付きインバウンドキューの後ろに置くべきです。