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:
- Accepts
message.receivedevents from UnifyPort’s unified webhook - Verifies every delivery with HMAC-SHA256 against your
signing_secret - Writes each X message to a
messages.jsonlfile - 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.receivedevent payload shape - The
x-unifyport-signatureheader and HMAC-SHA256 verification section - The
POST /v1/webhook-endpointscreation 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.