← All posts
Case Study

X OAuth Outage Drill: How a Small Team Kept DMs in a Signed Inbound Queue

On July 1, X’s developer status page showed a short but useful warning for support teams: OAuth2.0 login and /2/users/me returned 401 errors from June 30 23:00 UTC to July 1 01:00 UTC. The incident was resolved, and the status page later showed all systems operational. For a team that only glances at X once a day, that is background noise. For a small team whose support workflow starts by refreshing an OAuth token, calling /2/users/me, and then polling for activity, it is an incident drill.

This story is about a three-person launch team in Singapore that runs support from X, WhatsApp, and LINE during product drops. They did not lose customer messages because of the July incident. The useful lesson came from their postmortem: their own support pipeline had made X identity refresh the first step in every intake job. When a dependency at that first step returns 401, the rest of the job never starts.

The fix was not “never use the official X API.” The fix was to move live customer intake into a signed inbound queue, store every delivery first, and make provider APIs a reply or enrichment layer rather than the gate that decides whether the team can see a message.

The Fragile First Hop

The team’s first X integration was normal for 2026. It used the X API v2 with OAuth 2.0 PKCE, checked the authenticated user, then queried direct-message and mention surfaces. X’s own Direct Messages documentation describes Manage Direct Messages as endpoints for creating conversations, sending DMs, and deleting DM events on behalf of authenticated users. The prerequisites are also clear: an approved developer account, a project and app in the Developer Console, and user access tokens via OAuth 2.0 PKCE.

That official surface is reasonable when the job is “send this DM through X” or “manage a conversation through X’s developer platform.” The launch team’s operational job was different:

Customer writes on X, WhatsApp, or LINE
  -> support system receives the message
  -> event is stored before any AI or human workflow
  -> agent replies from the right account

The old job inverted that order. It asked X to prove the identity of the account before it put anything into the support queue. During normal days, nobody noticed. During an OAuth or /2/users/me incident, the queue had no new X activity because the intake script exited before it reached the routing step.

The team wanted two properties instead: messages should land as events, and the queue should not be shaped around one provider’s identity endpoint.

The New Intake Contract

They registered a UnifyPort webhook endpoint before touching the rest of the workflow:

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’s unofficial interface connects ordinary messaging accounts and delivers inbound activity as one standard event stream. For X, the provider value is twitter; for WhatsApp and LINE, the same envelope still applies. A received X DM lands like this:

{
  "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" }
  }
}

The important fields are boring on purpose: id, type, provider, account_id, occurred_at, and data. The support queue can route by values instead of learning a new event model for every channel.

Verify, Store, Then Route

Each delivery includes X-Device-Timestamp and, when signing is enabled, X-Device-Signature. The signature is a hex HMAC-SHA256 over the timestamp, a dot, and the raw request body, using the endpoint’s signing_secret. The team put this verifier in front of the queue:

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

The sequence matters more than the code. Verify the signature against the raw bytes. Store the event by id. Only then send it to Slack, a helpdesk, a CRM, or an AI triage job. UnifyPort’s docs are explicit that webhook events are the only record of inbound traffic; missed events are not replayed from a message-history API later. That means storage is part of the intake edge, not a nice-to-have downstream step.

What Changed During the Next Drill

Two weeks later, the team ran an internal drill. They blocked the job that used to call /2/users/me, left the webhook receiver online, and sent test messages to all three channels.

X, WhatsApp, and LINE messages entered the same message.received table. The Slack alerts were slower for X because the team intentionally paused an enrichment worker that fetched profile metadata, but the raw event was already stored. Agents could still see who wrote, when the message arrived, which account received it, and what the customer said. The provider-specific metadata could catch up later.

Replying stayed explicit. When an agent answered, the backend used POST /v1/messages with the connected account and recipient:

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."
  }
}'

This design does not make a platform outage disappear. If X itself is unavailable, every integration feels it. The difference is narrower and more practical: your support desk no longer depends on a profile lookup or a polling job succeeding before it can store customer activity that has already been delivered to your webhook.

The Checklist They Kept

The team’s runbook became short enough to review before every launch:

  1. Register the webhook first, with subscribed_events: ["message.received"].
  2. Keep signing enabled and verify X-Device-Signature against the raw body.
  3. Store every event by id before routing, enrichment, AI, or human assignment.
  4. Treat provider API calls as enrichment or reply steps, not as the intake gate.
  5. Route by provider, account_id, and data.conversation.id, so adding WhatsApp, LINE, Telegram, Zalo, or TikTok does not create another queue.

The July X incident was short. That made it a good test signal rather than a disaster. Small teams rarely get to remove every dependency on a platform API. They can, however, choose where that dependency sits. Put it after the signed inbound queue, not before the customer message reaches your team.