DEV Community

Alex
Alex

Posted on

API-first or browser automation? Lessons from shipping content autoposting

We built a pipeline that generates content and ships it to several platforms. Generation turned out to be the easy half. Publishing is where the real engineering was — and where I burned the most hours. Here is what I'd tell my past self.

The rule I landed on: API when it exists, browser only when it doesn't

Not every platform exposes a publishing API. The tempting shortcut is to drive a headless browser everywhere. Don't. Two reasons:

  1. Some platforms explicitly forbid it. X's automation rules are blunt: "Non-API-based forms of automation, such as scripting the X website, may result in permanent suspension." If an API exists, scripting the site is not a clever workaround — it's a ban waiting to happen.
  2. Browser automation is inherently fragile. It breaks the moment someone renames a CSS class.

So the rule: API adapter by default. Browser automation only where no API exists, and only for actions the account owner is allowed to perform.

What platforms actually allow

I checked the primary docs (not blog posts) for each. Official publishing APIs where automation of your own account is permitted:

Platform Auth Content
Telegram (Bot API) static bot token short / channel posts
Bluesky (AT Protocol) app password → session JWT short
Mastodon OAuth token short
DEV.to / Forem api-key header long-form Markdown
Ghost Admin API key → JWT long-form
LinkedIn OAuth (w_member_social) short/medium

The universal enforcement rule is remarkably consistent across all of them:

Posting your content to your account: fine.
Identical content across multiple accounts, bulk/aggressive actions, unsolicited notification spam: banned.

Vary content per platform, respect rate limits, don't automate interactions. That's the whole game.

Browser automation gotchas (the expensive ones)

1. "Am I logged in?" — don't assert on text anonymous users also see

My first check looked for words like "My feed" and "Write". Both are visible to logged-out visitors. The script cheerfully reported success while completely unauthenticated.

Assert on the absence of the thing that shouldn't be there:

async function isLoggedIn(page: Page): Promise<boolean> {
  const loginBtn = page.getByRole("button", { name: "Sign in", exact: true }).first();
  return !(await loginBtn.isVisible().catch(() => false));
}
Enter fullscreen mode Exit fullscreen mode

2. Native confirm() dialogs are not in the DOM

The publish button fired a native confirm(). I spent an embarrassing amount of time hunting for a second DOM button that never existed. Playwright auto-dismisses native dialogs unless you handle them:

page.on("dialog", (d) => d.accept());
Enter fullscreen mode Exit fullscreen mode

3. Rich editors: the body block may not exist yet

The empty editor had only a title field. The body block is created when you press Enter from the title. My naive "click the editor and type" appended the first paragraph into the title:

Title: My Article HeadlineThe first paragraph of my article...
Enter fullscreen mode Exit fullscreen mode

The fix is to type like a human:

await titleField.click();
await page.keyboard.type(title);
await page.keyboard.press("Enter");        // <- this creates the body block
for (const [i, p] of paragraphs.entries()) {
  await page.keyboard.type(p);
  if (i < paragraphs.length - 1) await page.keyboard.press("Enter");
}
Enter fullscreen mode Exit fullscreen mode

And then verify by reading it back. Don't trust a screenshot that "looked fine":

const titleText = await titleField.innerText();
const firstBody = await page.locator(".editor-text-tool").first().innerText();
Enter fullscreen mode Exit fullscreen mode

That single assertion caught the bug that three screenshots missed.

4. Sessions expire, quietly

A saved storageState worked beautifully — for about two to three weeks. Then a run came back auth_required. Build re-authentication in as a normal automated step, not a manual emergency.

API gotchas

Versioned APIs expire

LinkedIn requires a LinkedIn-Version: YYYYMM header and keeps roughly the last 12 months active. A hardcoded default that was fine at write-time returns this at run-time:

HTTP 426 — Requested version 20250501 is not active
Enter fullscreen mode Exit fullscreen mode

Anything with a date baked into it belongs in config, not in a constant. Same for token lifetime (60 days for LinkedIn) — treat rotation as a scheduled task, not a surprise.

The architecture that held up

  • One adapter contract per platform: validate → prepare → publish → verify → collectResult. Adding a platform means writing one file, not touching the orchestrator.
  • Secrets in env, never in the database. Outward (UI / API / logs) we expose only masked booleans: configured, publishEnabled. A token has never once been logged.
  • Idempotency by request hash: sha256(draftId + destinationId + contentHash + mode). A prior success → skip, return the existing URL. A prior unknown (the network died mid-request, we genuinely don't know if it landed) → never blind-retry; flag needs_manual_review. Duplicate posts are worse than a missing one.
  • Two flags to publish for real: dryRun=false and publishConfirm=true. Defaults are safe, so an accidental run can only ever produce a preview.
  • Stop, don't bypass. Captcha, 2FA, an emailed verification code → status waiting_human. We never solve a challenge. That line is what separates "a robot operating the owner's account" from "a bot pretending to be a human".

Takeaways

  1. API-first. Browser automation is a fallback, not a default — and on some platforms it's a ban.
  2. Read the automation policy before writing the adapter. It changes what you build, not just whether you're allowed.
  3. Assert on absence, not on presence of a happy-path string.
  4. Anything dated (API versions, tokens) goes in config. It will expire while you sleep.
  5. Make "publish for real" require two deliberate flags. Your future self will hit Enter on the wrong terminal eventually.

The unglamorous truth: the model that writes the post is the commodity. The reliability layer around it — idempotency, gating, honest failure states — is the actual product.

Top comments (0)