Introduction
I run a small bot that announces updates to my own sites on X.
My first plan was to use the X API. I ended up dropping it and posting through an IFTTT webhook instead.
IFTTT is a SaaS that connects "when A happens, do B". One of its inputs is Webhooks: you get a URL,
you send one HTTP request to it, and the applet you configured runs. In other words, you get
"one HTTP call posts to X" without app registration or token refresh code.
This post is about what that trade bought me, what it cost me, and how I covered the cost.
Short version: what I gave up was confirmation that the post actually happened, and the fix was
to measure it through a separate path.
The overall shape
Three pieces:
-
Generate - build the announcement text from the day's updated articles and push it into a
local
queue.json, each item carrying ascheduledAttimestamp. - Post - a launchd job (launchd is the macOS service manager: you drop an XML plist file and it handles daemons and scheduled jobs, filling the role systemd and cron do on Linux) wakes every 30 minutes and sends only the items whose time has come.
- Verify - read the public timeline separately and count what actually landed.
Generation and posting are split so the posts can be spread over a time window. If you generate and
send in the same pass, everything goes out at the same minute.
The core of the implementation
Sending is short. The IFTTT webhook takes fixed field names, value1 through value3.
const url = `https://maker.ifttt.com/trigger/${event}/with/key/${key}`;
const payload = { value1: tweetText };
if (imageUrl) payload.value2 = imageUrl;
const response = await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' }
});
With the X API, this is where OAuth 1.0a signing or OAuth 2.0 refresh-token handling would live,
plus app registration. For a personal announcement bot that was the heaviest part. The webhook removes it.
The problem starts after this line. The code above looks successful whenever it gets a 200.
But the IFTTT webhook returns 200 when:
- the applet is turned off
- the X connection has been revoked
- you have hit the free-tier applet limit
The webhook is answering "I accepted the event", not "the applet ran" and certainly not "it is on X".
I missed this, and my logs showed a 100% success rate the entire time delivery was completely dead.
I fixed it in two steps. First, stop looking only at the status code.
static extractError(data) {
if (data == null) return null;
if (typeof data === 'object') {
const errs = Array.isArray(data.errors) ? data.errors : null;
if (errs && errs.length) {
return errs.map(e => (e && e.message) || String(e)).join(' / ');
}
return null;
}
const text = String(data);
if (/"errors"\s*:/.test(text)) {
try { return IFTTTService.extractError(JSON.parse(text)); }
catch (e) { return text.slice(0, 200); }
}
if (/invalid key|not found|rate limit|too many requests/i.test(text)) {
return text.slice(0, 200);
}
return null;
}
IFTTT can return an error in the body while the status stays 200. Send a key with one character
changed and you get HTTP 200 plus {"errors":[{"message":"You sent an invalid key."}]}; the function
above pulls that string out.
Second, split the names. Instead of one success flag, the call returns accepted and published.
accepted means the request was taken. published means it is live - and published is always
null (unknown), because the webhook cannot tell you.
Not writing true for something you cannot observe was the single most useful decision here.
Things that bit me
The posting job runs under macOS launchd with StartInterval set to 1800 (every 30 minutes).
<key>ProgramArguments</key>
<array>
<string>/Users/hashito/.nodebrew/current/bin/node</string>
<string>/path/to/src/index.js</string>
<string>--process</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/hashito/.nodebrew/current/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StartInterval</key>
<integer>1800</integer>
node is an absolute path because launchd does not go through a login shell, so the nodebrew PATH
from .zshrc never arrives. The only evidence was node: command not found in the log. Setting
PATH explicitly in EnvironmentVariables fixed it.
The other one: a Mac sleeps. An interval job does not fire while asleep. launchd runs the missed
invocation after wake, so if the queue is ordered by time, the backlog drains on wake in order.
Give up on holding the interval, and let the queue hold the ordering - then sleep turns from a
failure into a delay.
The result
One of the sites this bot announces: https://gadget.autoarticles.net
Takeaway
Dropping the X API for a webhook removed all the auth code. It also removed the answer to
"did it post?".
Generalised: always check how far an external service's success response actually reaches.
If a response only guarantees "accepted" and you record it as "completed", you lose the ability to
detect breakage. Measure the unguaranteed part through another path, or at minimum keep it as
null / unknown. The moment you fill it with true, your monitoring starts lying to you.
This article is about my own side project. It was written with AI assistance.
Top comments (0)