Apify is great at orchestration (Actors, scheduling, datasets). browser.city is great at stealth browsing + extraction. Pair them when you want:
- reliable anti-bot handling (Cloudflare / DataDome / PerimeterX class targets)
- clean markdown output for pipelines
- predictable infra primitives (sessions, egress, fingerprints)
Pattern A (recommended): Apify orchestrates, browser.city extracts
If you don’t need multi-step interaction, use the Request API and let Apify handle concurrency and storage.
Batch fetch + markdown (Node.js Actor)
main.ts
import { Actor } from "apify";await Actor.init();const input = (await Actor.getInput()) as { urls: string[] };const res = await fetch("https://api.browser.city/v1/requests/batch", { method: "POST", headers: { Authorization: `Bearer ${process.env.BROWSERCITY_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ requests: input.urls.map((url) => ({ url, markdown: true })) }),}).then((r) => r.json());for (const item of res.responses) await Actor.pushData(item);await Actor.exit();using System.Net.Http.Headers;using System.Net.Http.Json;var http = new HttpClient();http.DefaultRequestHeaders.Authorization = new( "Bearer", Environment.GetEnvironmentVariable("BROWSERCITY_API_KEY"));var res = await (await http.PostAsJsonAsync( "https://api.browser.city/v1/requests/batch", new { requests = input.Urls.Select(url => new { url, markdown = true }) })) .Content.ReadFromJsonAsync<BatchResponse>();Console.WriteLine($"{res?.SuccessCount} ok, {res?.ErrorCount} failed");record Input(string[] Urls);record BatchResponse(int SuccessCount, int ErrorCount);import com.fasterxml.jackson.databind.ObjectMapper;import java.net.URI;import java.net.http.*;public class Batch { public static void main(String[] args) throws Exception { var req = HttpRequest.newBuilder(URI.create("https://api.browser.city/v1/requests/batch")) .header("Authorization", "Bearer %s".formatted(System.getenv("BROWSERCITY_API_KEY"))) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString("{\"requests\":[{\"url\":\"https://example.com\",\"markdown\":true}]}")) .build(); var res = new ObjectMapper().readValue( HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body(), BatchResponse.class); System.out.println(res.successCount() + " ok, " + res.errorCount() + " failed"); } record BatchResponse(int successCount, int errorCount) {}}
Why this pattern wins:
- fewer moving parts than driving Playwright inside the Actor
- better cost/control for simple extraction workloads
- easier retry logic (retry individual URLs without keeping a browser alive)
Pattern B: Apify runs Playwright, browser.city provides the remote browser
For logins and multi-step flows, create a browser.city session and connect with Playwright.
session.ts
import { chromium } from 'playwright';const { endpoint, token, id } = await fetch('https://api.browser.city/v1/sessions', { method: 'POST', headers: { Authorization: `Bearer ${process.env.BROWSERCITY_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ browser: 'chromium' }),}).then((r) => r.json());try { const browser = await chromium.connect(endpoint, { headers: { Authorization: `Bearer ${token}` }, }); const page = browser.contexts().at(0)!.pages().at(0)!; await page.goto('https://example.com'); console.log(await page.title());} finally { await fetch(`https://api.browser.city/v1/sessions/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.BROWSERCITY_API_KEY}` }, });}using Microsoft.Playwright;using System.Net.Http.Headers;using System.Net.Http.Json;var http = new HttpClient();http.DefaultRequestHeaders.Authorization = new( "Bearer", Environment.GetEnvironmentVariable("BROWSERCITY_API_KEY"));var session = await (await http.PostAsJsonAsync( "https://api.browser.city/v1/sessions", new { browser = "chromium" })) .Content.ReadFromJsonAsync<Session>();var (id, endpoint, token) = session!;IPlaywright? pw = null;try { pw = await Playwright.CreateAsync(); var browser = await pw.Chromium.ConnectAsync(endpoint, new() { Headers = new() { ["Authorization"] = $"Bearer {token}" } }); var page = browser.Contexts[0].Pages[0]; await page.GotoAsync("https://example.com");} finally { try { if (session is not null) await http.DeleteAsync($"https://api.browser.city/v1/sessions/{id}"); } finally { pw?.Dispose(); }}record Session(string Id, string Endpoint, string Token);import com.fasterxml.jackson.databind.ObjectMapper;import com.microsoft.playwright.*;import java.net.URI;import java.net.http.*;import java.util.Map;public class Session { public static void main(String[] args) throws Exception { var key = System.getenv("BROWSERCITY_API_KEY"); var http = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder(URI.create("https://api.browser.city/v1/sessions")) .header("Authorization", "Bearer %s".formatted(key)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString("{\"browser\":\"chromium\"}")) .build(); var created = new ObjectMapper().readValue( http.send(req, HttpResponse.BodyHandlers.ofString()).body(), CreatedSession.class); var id = created.id(); var endpoint = created.endpoint(); var token = created.token(); Playwright pw = null; try { pw = Playwright.create(); var browser = pw.chromium().connect(endpoint, new BrowserType.ConnectOptions().setHeaders(Map.of("Authorization", "Bearer %s".formatted(token)))); var page = browser.contexts().get(0).pages().get(0); page.navigate("https://example.com"); } finally { try { http.send(HttpRequest.newBuilder(URI.create("https://api.browser.city/v1/sessions/" + id)) .header("Authorization", "Bearer %s".formatted(key)).DELETE().build(), HttpResponse.BodyHandlers.discarding()); } finally { if (pw != null) pw.close(); } } } record CreatedSession(String id, String endpoint, String token) {}}
From here you can attach your existing Playwright scripts or crawler logic to browser.
Operational tips
- Store the key as an Apify secret/env var:
BROWSERCITY_API_KEY. - Use
/v1/requests/batchto reduce per-URL overhead when you can. - Use
egressandfingerprintoptions when you need consistent geo/device behavior across runs.