Skip to content
Back to all work
Case Study

8 Platforms, 2 Strategies

When to Use the API and When to Open a Browser

Lessons from building a cross-platform social inbox with JSON APIs, AT Protocol, and Playwright.

12 min read|TypeScriptPlaywrightAT ProtocolREST APIsBrowser Automation
Social Inbox
12/13 online
Platforms
X
API2m
Instagram
API5m
Reddit
API12m
LinkedIn
Browser1h
TikTok
API8m
Bluesky
API3m
Threads
API15m
Hacker News
Hybrid8m
YouTube
API30m
Dev.to
Hybrid25m
Mastodon
API45m
Discord
Browser2h
IndieHackers
Browser4d
Queue
Redditr/nextjs
publishing next
Instagramcarousel
via Graph API
Blueskyreply
via AT Proto
LinkedIncomment
selector timeout, retry 8m
Engagement Inbox3 new
r/selfhosted·2m ago
92% match

Looking for self-hosted alternatives to Buffer that don't require a dozen OAuth tokens...

ApproveEditSkipvia JSON API
X @indiehackers·8m ago
88% match

Thread: The real cost of building on platform APIs that change every quarter

ApproveEditSkipvia v2 API
Dev.to #typescript·45m ago

Has anyone built cross-platform posting tools? Struggling with LinkedIn's API limitations

AI drafting response...via browser fallback
Published47
Approval rate94%
Avg response3.2m

The Problem

Every social platform wants you to use their app. Some give you an API. Some give you a broken API. Some give you nothing.

I needed to monitor, draft, and post across eight platforms from a single terminal. Not a SaaS dashboard with OAuth buttons and monthly pricing — a local CLI that runs on my machine, stores sessions in my home directory, and doesn't phone home.

Each platform takes a different stance on automation. Bluesky hands you a full protocol (AT Protocol) and says "build whatever you want." Reddit offers OAuth and a JSON API. Dev.to has a REST API that works for some things and silently fails for others. LinkedIn actively fights automation at every layer. IndieHackers doesn't have an API at all.

Two bad options. All-browser: spin up a Playwright instance for everything, click buttons, fill textareas. It works, but it's slow, fragile, and platforms are getting better at detecting it. All-API: clean and fast, but half the platforms don't offer one, and the ones that do are often incomplete.

The third option — the one I built — was to decide per-platform, per-operation. Some reads go through JSON APIs. Some writes go through browsers. Some platforms support both strategies simultaneously, selected at runtime.

The Decision Matrix

Every cell in this table is a decision. Green means a stable API handles the job. Amber means Playwright opens a browser. Red means the API exists on paper but doesn't work.

PlatformMonitorPostComment
RedditJSON APIAPI / BrowserAPI / Browser
Hacker NewsFirebase APIBrowser
Dev.toREST APIREST APIBrowser *
BlueskyAT ProtocolAT ProtocolAT Protocol
MastodonREST APIREST APIREST API
LinkedInBrowserOAuth / BrowserBrowser
IndieHackersBrowserBrowserBrowser
DiscordBrowser + GQLBrowserBrowser
API Browser Hybrid Broken API

The decision criteria are simple:

  1. 1Does a stable, documented API exist? Use it.
  2. 2Does an API exist but break? Browser fallback.
  3. 3Is there no API at all? Browser is the only option.
  4. 4Does the platform actively detect automation? Add evasion and delays.

One interface, two implementations

The orchestrator doesn't know or care whether it's hitting an API or a browser. Every adapter implements the same contract:

TypeScript
export interface PlatformAdapter {
  readonly name: string;
  readonly capabilities: readonly Capability[];
  readonly authType: AuthType;

  post?(content: PostContent): Promise<PostResult>;
  comment?(target: ThreadTarget, content: string): Promise<CommentResult>;
  monitor?(input: MonitorInput): Promise<Opportunity[]>;
  healthCheck?(): Promise<HealthResult>;
}

War Story: Reddit's Dual Mode

Reddit is the clearest example because it supports both strategies simultaneously.

The adapter has a mode property: "api" or "browser". In API mode, it uses Snoowrap (a Reddit OAuth wrapper) for posting and commenting. In browser mode, it navigates to old.reddit.com via Playwright.

Why old.reddit.com? The redesign has obfuscated CSS class names that change between deploys. Good luck writing a stable selector for the comment textarea. Old Reddit? textarea[name="title"]. That selector has been stable for fifteen years.

TypeScript
public async post(content: PostContent): Promise<PostResult> {
  if (this.mode === "browser") {
    return this.postViaBrowser(content, subreddit, title);
  }
  return this.postViaApi(content, subreddit, title);
}

Five lines that route the entire posting flow.

Safety guards the API doesn't need

The browser path needs guardrails that the API path doesn't. Two in particular.

The self-reply guard. Before posting a comment, the adapter checks whether the logged-in user is also the comment author. Without this, you'll happily reply to yourself. It sounds obvious in retrospect. It wasn't obvious the first time it happened.

The duplicate guard scans existing replies in the thread for your own username. If you're already there, skip. These guards exist because browser automation doesn't have the implicit safety rails that well-designed APIs provide. When you're clicking buttons, you're on your own.

TypeScript
const loggedInUser = await page.locator(".user a").first().textContent();
const targetAuthor = await page.locator(".comment .author").first().textContent();

if (loggedInUser?.toLowerCase() === targetAuthor?.toLowerCase()) {
  return { success: false, error: "Self-reply blocked" };
}

War Story: LinkedIn's Hostile DOM

LinkedIn is the hardest platform and the best teacher.

The first discovery took hours: the "Message" button on a LinkedIn profile isn't a <button>. It's an <a> tag. If you're searching for page.getByRole('button', { name: 'Message' }), you'll search forever. The DOM doesn't care about your assumptions.

The second problem: localization. My LinkedIn runs in Dutch. The "Post" button says "Plaatsen". "Next" says "Volgende". "Delete" says "Verwijderen". You can't hardcode English labels.

TypeScript
const POST_BUTTON  = /Plaatsen|Post|Delen|Share/i;
const NEXT_BUTTON  = /Volgende|Next/i;
const DELETE_BTN   = /Verwijderen|Delete/i;

const EDITOR_SELECTOR =
  '[role="dialog"] [contenteditable="true"], .ql-editor';

Regex patterns that survive language settings.

The regex approach wasn't designed. It was born from debugging at 2am. But it's the correct pattern — I should have started there instead of discovering it through failure.

LinkedIn also doesn't use infinite scroll in its feed. There's a "Load more" button. You click it, wait for new content to appear, check whether the post count actually increased, and bail if it didn't. Manual pagination in 2026.

Three different authentication strategies: persistent browser session for most operations, OAuth token for company page posting, and the hybrid where commenting always requires the browser even when you have an OAuth token.

When the API Lies

Dev.to has a comment API endpoint. It's documented. It accepts the right parameters. And it returns 404. Every time. This has been the case since at least 2025.

So monitoring and posting go through the REST API (they work fine). Comments go through the browser. The textarea selector tries four variants in cascade because even the browser DOM isn't stable across deploys.

Other platforms, other lessons

  • IndieHackers runs on Ember.js. Playwright's fill() doesn't trigger Ember's data binding. You need keyboard.type() with a deliberate delay between keystrokes, or the framework drops characters.
  • Discord adds random 2-3.5 second delays between actions to avoid bot detection. The delays aren't for show — without them, Discord silently rate-limits your session.
  • Upwork has no public API at all. The adapter intercepts GraphQL responses from the network layer — Playwright's page.on('response') captures the JSON payloads that the frontend fetches, without ever needing to parse the DOM.
"Has an API" and "has a working API" are different things. Your adapter needs to survive in the gap between what a platform promises and what it delivers.

Making It Reliable

Getting it to work is the easy part. Getting it to work at 3am, unattended, is the engineering.

Health tracking

Each platform has a staleness timer. If no successful action in 72 hours, it's marked stale and skipped during preflight checks. The health module tracks the last successful action per platform, calculates staleness, and reports it in a dashboard.

Exponential backoff

Failed queue items get three retry attempts: 10 minutes, 20 minutes, 40 minutes. After three failures, the status flips to permanently_failed and waits for human intervention. Each retry preserves the previous error for debugging.

Capability checking

Before calling adapter.comment(), the orchestrator verifies hasCapability(adapter, 'comment'). If the adapter doesn't support it, you get a clear error instead of a crash. This is the difference between a system that fails gracefully at 3am and one that takes down the entire run because HackerNews doesn't support direct posting.

TypeScript
const BACKOFF_BASE_MS = 5 * 60 * 1000; // 5 minutes

const newRetryCount = retryCount + 1;
const backoffMs = Math.pow(2, newRetryCount) * BACKOFF_BASE_MS;
const nextEligibleAt = new Date(
  now.getTime() + backoffMs
).toISOString();

// Retry 1: 10 min
// Retry 2: 20 min
// Retry 3: 40 min
// Then:    permanently_failed

The retry schedule: escalate, then give up honestly.

What I'd Do Differently

Start API-first, browser as last resort.

I built some browser adapters before checking whether a JSON API existed. Reddit has an excellent JSON API for reading — append .json to any URL. I built the browser monitoring first and discovered the API later. Wasted effort.

Persistent sessions from day one.

Storing cookies in a local directory and reusing them between runs eliminates 90% of authentication headaches. One-time manual login, then the adapter picks up where you left off. Should have adopted this pattern immediately instead of fighting OAuth flows.

Design for multilingual DOMs upfront.

The regex approach for LinkedIn (matching Dutch and English button labels simultaneously) works well. But I discovered it through failure instead of designing for it. If you're building browser automation for a multilingual audience, assume the DOM speaks a language you don't control.

Make health checks mandatory.

Only Bluesky and Mastodon implement healthCheck() in their adapters. The rest rely on staleness heuristics. A platform can be "fresh" (recent successful action) and still have broken authentication. That's a gap.

The Takeaway

The hybrid approach isn't elegant. It's a pragmatic response to a world where every platform has different opinions about automation. Each one gets the integration strategy it deserves — not a one-size-fits-all wrapper that works poorly everywhere.

The engineering judgment isn't in choosing API or browser. It's in knowing when to switch, what to guard against, and what to do when the platform changes the rules overnight.

This is the kind of systems engineering we do at MAD IT — messy real-world integrations that work reliably.