DEV Community

takahiro hashito
takahiro hashito

Posted on

Why my announcement bot posts through an IFTTT webhook instead of the X API

Background

I wanted the sites I run to announce their own updates on X. Small bot, simple job.

I started out planning to use the X API. After looking at the free-tier limits, the app review, and the ongoing token rotation, I ended up posting through an IFTTT webhook instead. This post is about that trade-off, and about the link-check gate that runs immediately before every post.

How it works

Generation and posting are two separate programs, and the operating system's scheduler sits between them.

  • Generation: a Node script turns the day's updates into announcement text plus a link, attaches a scheduled time, and appends it to a local queue.json.
  • Posting: launchd (the macOS service manager that starts jobs on a schedule) wakes periodically, picks the queued items whose time has arrived, and POSTs them to an IFTTT webhook. An IFTTT applet receives that and posts to X.

queue.json stays local and is not committed. Splitting generation from posting is what makes the rest of this manageable — the queue is the only interface between them, so the transport at the far end became replaceable.

Implementation

Posting is one JSON POST

Instead of calling the X API, the bot POSTs to an IFTTT Maker webhook. Authentication is a key embedded in the URL, so there is no OAuth token to refresh. IFTTT maps value1 to the body and value2 to an image URL.

async sendTweet(tweetText, imageUrl = null, webhookUrl = null) {
  const url = webhookUrl || this.baseUrl;      // per-account webhook can override
  const payload = { value1: tweetText };
  if (imageUrl) payload.value2 = imageUrl;      // attach the og:image
  const res = await this.client.post(url, payload, {
    headers: { "Content-Type": "application/json" },
  });
  if (res.status !== 200) throw new Error(`IFTTT failed: ${res.status}`);
  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

Allowing the webhook URL to be swapped per account means one posting path serves both a general account and several genre-specific ones. Queue entries carry an optional webhook and fall back to the default.

A link check immediately before posting

The worst failure mode in an automated announcement is linking to an article whose deploy has not propagated yet — a 404, posted confidently. So reachability is verified right before the post. HEAD first, falling back to GET when a server refuses or does not support it.

async function checkUrl(url, { timeoutMs = 8000, client = axios } = {}) {
  const opts = { timeout: timeoutMs, maxRedirects: 5, validateStatus: () => true };
  const evaluate = (status) => ({
    ok: status >= 200 && status < 400,
    status,
    definitive: status >= 400 && status < 600,   // permanently broken
    reason: status < 400 ? "ok" : `http-${status}`,
  });
  try {
    const head = await client.head(url, opts);
    if ([403, 405, 501].includes(head.status)) {  // common "HEAD unsupported" codes -> retry with GET
      return evaluate((await client.get(url, opts)).status);
    }
    return evaluate(head.status);
  } catch {
    // network error / timeout -> status unknown. never exclude permanently
    return { ok: false, status: null, definitive: false, reason: "network-error" };
  }
}
Enter fullscreen mode Exit fullscreen mode

The point is separating "definitely broken" (4xx/5xx) from "temporarily unknown" (network failure). The first is dropped from the queue forever; the second is skipped this round and retried next time.

const res = await checkUrl(q.url);
if (!res.ok) {
  if (res.definitive) { q.brokenUrl = true; q.brokenReason = res.reason; } // permanent
  else { /* possibly transient -> retry on the next process run */ }
  continue;   // either way, not posted this round
}
Enter fullscreen mode Exit fullscreen mode

Collapse those two and one blip permanently discards a perfectly good URL, which nothing will ever announce again.

Only what is due, and only once

The posting run selects items whose time has come and that have not gone out, recording posted URLs in posted.json:

const due = queue.filter(q =>
  !q.posted && !q.brokenUrl &&
  new Date(q.scheduledAt).getTime() <= now && !postedUrls.has(q.url));
Enter fullscreen mode Exit fullscreen mode

This is what makes the run repeatable — running it twice does not post twice, because the second pass sees the entries already marked. It also handles a laptop that was asleep when a scheduled time passed: the next run simply finds several items due and sends them together.

Gotchas — which double as the reasons I left the X API

  • Credential operations disappear. The X API brings rate-limit monitoring, token expiry and re-issuance, and app review with it. For a small personal integration, a single webhook is less to keep alive.
  • Guard against double posting. A posted flag plus the set of already-sent URLs in posted.json makes a repeated run a no-op rather than a duplicate.
  • RunAtLoad=false. The posting job runs on StartInterval (30 minutes) only. With RunAtLoad set to true, an unscheduled post fires the instant the job is loaded — which happens on login, on reload, and every time you edit the job.
  • Accept what the webhook cannot do. Threads, quote posts, and fine-grained control are weaker than with the API. For announcements that was fine, so the trade was a thinner integration in exchange for fewer features.

The result

One of the sites whose updates are announced this way: https://gadget.autoarticles.net

Wrap-up

Even for something as simple as "site updated, tell people", the choice between calling an API directly and passing through a webhook changes the ongoing cost far more than the code does.

If durability matters more than capability, this shape held up well: split generation from posting behind a queue, verify link reachability immediately before posting (distinguishing permanent from transient failure), and let the OS scheduler own execution. The transport at the end becomes a detail you can swap without touching anything upstream — which is worth more than any individual feature the API would have given me.


This article is about my own side project. It was written with AI assistance.

Top comments (0)