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 |
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 priorsuccess→ skip, return the existing URL. A priorunknown(the network died mid-request, we genuinely don't know if it landed) → never blind-retry; flagneeds_manual_review. Duplicate posts are worse than a missing one.
Two flags to publish for real:dryRun=falseandpublishConfirm=true. Defaults are safe, so an accidental run can only ever produce a preview.
Stop, don't bypass. Captcha, 2FA, an emailed verification code → statuswaiting_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.
SOCIAL SHARE CARD GENERATOR