DEV Community

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

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:

  • 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.
  • 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));
}

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());

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...

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");
}

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();

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

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

  • API-first. Browser automation is a fallback, not a default - and on some platforms it's a ban.
  • Read the automation policy before writing the adapter. It changes what you build, not just whether you're allowed.
  • Assert on absence, not on presence of a happy-path string.
  • Anything dated (API versions, tokens) goes in config. It will expire while you sleep.
  • 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.

Comments

No comments yet. Start the discussion.