← All posts
Tutorial

I Had Claude Code Build an X DM and Mention Monitor — Without Touching X's API — UnifyPort

X’s API has no free tier. Reading a DM costs $0.015. Reading a post costs $0.005. If you’re a two-person team that just wants to know when someone messages or mentions you on X, the official developer platform asks you to load credits, create a project, wait for approval, and start a metered tab — before you process a single event.

I skipped all of that. I opened Claude Code, pasted the UnifyPort webhook reference into its context, and asked it to build an X DM and mention monitor in Node.js. Forty minutes later I had an Express server that verifies every delivery signature, writes each event to a structured log file, and pushes alerts to a Discord channel. No X developer account, no per-read billing, no approval queue.

The demo you’ll end up with

A Node.js Express server that:

  1. Accepts message.received events from UnifyPort’s unified webhook
  2. Verifies every delivery with HMAC-SHA256 against your signing_secret
  3. Writes each X message to a messages.jsonl file
  4. Posts an alert to a Discord channel via webhook

Time: under an hour. You’ll need a UnifyPort workspace with an X account connected via the UnifyPort Exporter browser extension — a one-click session import, no developer credentials — and a Discord webhook URL.

Setup: get the docs into Claude Code’s context

Before prompting, feed the API reference into the agent. Claude Code reads files, so create a unifyport-reference.md in your project root with:

  • The message.received event payload shape
  • The x-unifyport-signature header and HMAC-SHA256 verification section
  • The POST /v1/webhook-endpoints creation call

Or paste those sections directly into the conversation. The critical part: real field names go in, correct code comes out. Without the reference, the agent will invent an X-specific SDK or a dm.received event type that doesn’t exist.

Cursor, Windsurf, and Copilot work the same way — use @Docs, the docs panel, or paste the reference into context. The tool is secondary; the docs are primary.

The build, prompt by prompt

First prompt — the skeleton:

Read unifyport-reference.md. Write an Express server in server.js with a POST /webhook route. When the event is “message.received”, log the provider, from, and text fields. Return 200 for everything.

Claude Code produces:

import express from "express";

const app = express();
app.use(express.json());

app.post("/webhook", (req, res) => {
  const evt = req.body;
  if (evt.event === "message.received") {
    console.log(`[${evt.provider}] ${evt.from}: ${evt.text}`);
  }
  res.sendStatus(200);
});

app.listen(3000, () => console.log("listening on :3000"));

Six lines of handler. Because the event shape is the same across all six platforms, there’s no X-specific parsing — evt.provider says "x", but the structure is identical to a WhatsApp or Telegram message.

Second prompt — signature verification:

Each delivery includes an x-unifyport-signature header. Verify it with HMAC-SHA256 using a signing_secret from the environment. The HMAC must run over the raw request body — not re-serialized JSON. Return 401 if it fails. Use a timing-safe comparison.

Claude Code adds the verification layer:

import crypto from "crypto";

const SIGNING_SECRET = process.env.UNIFYPORT_SIGNING_SECRET;

app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; }
}));

function isValid(req) {
  const sig = req.get("x-unifyport-signature") || "";
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(req.rawBody)
    .digest("hex");
  return sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

The verify callback captures the raw buffer before Express parses it — if the agent had used req.body and re-serialized to JSON, the HMAC would never match. Because the prompt said “raw request body,” it got this right without a correction round.

Third prompt — logging and Discord alerts:

Add two features: (1) append each message.received event to messages.jsonl — one JSON object per line with timestamp, provider, from, text, and message_id. (2) Post a one-line alert to a Discord webhook URL from the DISCORD_WEBHOOK_URL environment variable.

The complete server.js:

import express from "express";
import crypto from "crypto";
import { appendFileSync } from "fs";

const SIGNING_SECRET = process.env.UNIFYPORT_SIGNING_SECRET;
const DISCORD_WEBHOOK = process.env.DISCORD_WEBHOOK_URL;

const app = express();
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; }
}));

function isValid(req) {
  const sig = req.get("x-unifyport-signature") || "";
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(req.rawBody)
    .digest("hex");
  return sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

app.post("/webhook", async (req, res) => {
  if (!isValid(req)) return res.sendStatus(401);

  const evt = req.body;
  if (evt.event === "message.received") {
    const record = JSON.stringify({
      timestamp: evt.timestamp,
      provider: evt.provider,
      from: evt.from,
      text: evt.text,
      message_id: evt.message_id,
    });
    appendFileSync("messages.jsonl", record + "\n");

    if (DISCORD_WEBHOOK) {
      await fetch(DISCORD_WEBHOOK, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          content: `**[${evt.provider}]** ${evt.from}: ${evt.text}`,
        }),
      });
    }
  }
  res.sendStatus(200);
});

app.listen(3000, () => console.log("listening on :3000"));

Under 50 lines. Signature-verified, logged to disk, forwarded to Discord. The handler doesn’t branch on provider because it doesn’t need to — the event shape is the same whether the message came from X, WhatsApp, or any other channel.

Run it and watch an X message arrive

Connect your X account in the UnifyPort dashboard. X uses session import via the UnifyPort Exporter browser extension — click the extension icon, authorize, and your account is linked in seconds. No developer portal, no API key application, no approval queue.

Register a webhook endpoint pointed at your server with subscribed_events: ["message.received"] and a signing_secret. Start the server, then send a test DM to your X account from another account. The event arrives:

{
  "event": "message.received",
  "account_id": "acct_7Kp3mR",
  "provider": "x",
  "from": "user_9d4f2b",
  "text": "Hey, are you still taking freelance projects?",
  "timestamp": 1750521600,
  "message_id": "x_msg_8c1e5a"
}

Your server verifies the signature, appends the record to messages.jsonl, and posts the alert to Discord. If the signature check fails — 401 — double-check that UNIFYPORT_SIGNING_SECRET matches the value you set on the webhook endpoint.

Extend it: add Telegram with zero new code

Connect a Telegram account in the same workspace, subscribe the same webhook endpoint. A Telegram message arrives in the exact same shape — different provider field, identical structure:

{
  "event": "message.received",
  "account_id": "acct_2Xn5wL",
  "provider": "telegram",
  "from": "user_4a7c8d",
  "text": "Can you hop on a call tomorrow?",
  "timestamp": 1750521660,
  "message_id": "tg_msg_3b9f1e"
}

No new handler. No Telegram-specific parsing. The messages.jsonl file now contains both X and Telegram events in the same schema, and Discord shows alerts from both streams. Adding WhatsApp, LINE, Zalo, or TikTok later is the same one-step process — connect the account and the handler you already built processes everything.

The difference between this and building against X’s API directly isn’t just cost — it’s access. X requires an approved developer account, a funded project, and a per-read billing commitment. UnifyPort’s session-import path connects your personal X account in the browser and normalizes every DM and mention into the same event shape as the other five channels. Point Claude Code at that surface — one event schema, one signature scheme — and you’ll have a verified, logging, alerting listener running before lunch. Then add every other channel without touching the handler.