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 apiKey = process.env.BROWSERCITY_API_KEY!;const opts = { method: "POST", headers: { Authorization: `Bearer ${apiKey}` } };const input = (await Actor.getInput()) as { urls: string[] };const res = await fetch("https://api.browser.city/v1/requests/batch", { ...opts, body: JSON.stringify({ // Up to 100 per batch; each item can override requestOptions/markdown/render requests: input.urls.map((url) => ({ url, markdown: true })), }),}).then((r) => r.json());for (const item of res.responses) { await Actor.pushData({ url: item.url, status: item.status, contentType: item.contentType, markdown: item.contentType === "markdown" ? item.content : null, error: item.error ?? null, });}await Actor.exit();import osimport requestsapi_key = os.environ["BROWSERCITY_API_KEY"]urls = ["https://example.com", "https://example.org"]res = requests.post( "https://api.browser.city/v1/requests/batch", headers={"Authorization": f"Bearer {api_key}"}, json={"requests": [{"url": u, "markdown": True} for u in urls]},).json()for item in res["responses"]: print(item["url"], item["status"], item.get("error"))using System.Net.Http.Headers;using System.Net.Http.Json;var apiKey = Environment.GetEnvironmentVariable("BROWSERCITY_API_KEY")!;var http = new HttpClient();http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);var res = await http.PostAsJsonAsync( "https://api.browser.city/v1/requests/batch", new { requests = new[] { new { url = "https://example.com", markdown = true }, new { url = "https://example.org", markdown = true }, } });Console.WriteLine(await res.Content.ReadAsStringAsync());import java.net.URI;import java.net.http.*;var apiKey = System.getenv("BROWSERCITY_API_KEY");var http = HttpClient.newHttpClient();var body = "{\"requests\":[{\"url\":\"https://example.com\",\"markdown\":true},{\"url\":\"https://example.org\",\"markdown\":true}]}";var req = HttpRequest.newBuilder() .uri(URI.create("https://api.browser.city/v1/requests/batch")) .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString(body)) .build();var res = http.send(req, HttpResponse.BodyHandlers.ofString());System.out.println(res.body());
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 apiKey = process.env.BROWSERCITY_API_KEY!;const opts = { method: "POST", headers: { Authorization: `Bearer ${apiKey}` } };const session = await fetch("https://api.browser.city/v1/sessions", { ...opts, body: JSON.stringify({ browser: "chromium", egress: { mode: "managed", proxyType: "residential", country: "US" }, }),}).then((r) => r.json());const browser = await chromium.connect(session.endpoint, { headers: { Authorization: `Bearer ${session.token}` },});import osimport requestsfrom playwright.sync_api import sync_playwrightapi_key = os.environ["BROWSERCITY_API_KEY"]session = requests.post( "https://api.browser.city/v1/sessions", headers={"Authorization": f"Bearer {api_key}"}, json={"browser": "chromium"},).json()with sync_playwright() as p: browser = p.chromium.connect( session["endpoint"], headers={"Authorization": f"Bearer {session['token']}"}, )using Microsoft.Playwright;using System.Net.Http.Headers;using System.Net.Http.Json;var apiKey = Environment.GetEnvironmentVariable("BROWSERCITY_API_KEY")!;var http = new HttpClient();http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);var res = await http.PostAsJsonAsync( "https://api.browser.city/v1/sessions", new { browser = "chromium" });var session = await res.Content.ReadFromJsonAsync<Session>() ?? throw new Exception("bad response");using var pw = await Playwright.CreateAsync();var browser = await pw.Chromium.ConnectAsync(session.endpoint, new() { Headers = new Dictionary<string, string> { ["Authorization"] = $"Bearer {session.token}", },});public record Session(string endpoint, string token);import com.microsoft.playwright.*;import java.net.URI;import java.net.http.*;import java.util.regex.*;var apiKey = System.getenv("BROWSERCITY_API_KEY");var http = HttpClient.newHttpClient();var res = http.send( HttpRequest.newBuilder() .uri(URI.create("https://api.browser.city/v1/sessions")) .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString("{\"browser\":\"chromium\"}")) .build(), HttpResponse.BodyHandlers.ofString());var endpoint = extractJsonString(res.body(), "endpoint");var token = extractJsonString(res.body(), "token");try (var pw = Playwright.create()) { pw.chromium().connect( endpoint, new BrowserType.ConnectOptions().setHeaders( java.util.Map.of("Authorization", "Bearer " + token)));}static String extractJsonString(String json, String key) { var m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]+)\"").matcher(json); if (!m.find()) throw new RuntimeException("missing " + key); return m.group(1);}
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.