Automating Multi-Account Browser Profiles From C# With PuppeteerSharp and the REST API
Most browser-automation tutorials assume JavaScript or Python. If your backend is ASP.NET Core, your infrastructure is on Azure, or your team simply thinks in C#, there is no reason to switch languages just to drive a browser.
PuppeteerSharp connects to Chrome through the DevTools Protocol. JustBrowser, a multi-account browser for teams, exposes exactly that: start a profile through its local REST API, get back a CDP WebSocket URL, and attach PuppeteerSharp to it. Each profile is its own browser, with its own cookies, storage, proxy and identity parameters, so the accounts and test environments you run stay separate.
What We're Building
By the end of this, you'll have:
- A C# method that starts JustBrowser profiles via the local REST API
- PuppeteerSharp connected to those profiles over a CDP WebSocket
- Your existing automation code running against a profile with 40+ identity parameters
- Profile lifecycle management: start, connect, disconnect, stop
Your PuppeteerSharp code stays the same. You are only changing which browser it talks to. JustBrowser is built on a custom Chromium engine with native C++ integration, patched on every upstream release, rather than an extension layered on stock Chromium.
Prerequisites
- .NET 6+ (examples use .NET 8, but 6/7 work fine)
- PuppeteerSharp 19+ via NuGet
- JustBrowser ($9.99/mo). The REST API is available from day one of the 7-day trial
System.Net.Http.Jsonfor JSON handling (included in .NET 6+)- Basic familiarity with async/await in C#
- Windows x64 or macOS (Apple Silicon). The desktop app has to run on the machine that hosts your automation
Typical legitimate uses for this setup include QA across locales and time zones, ad verification, working inside client accounts you have been given access to, and automating your own internal workflows. Use it only on accounts and sites you are allowed to automate.
Step 1: Create Your Profile in JustBrowser
Before any code runs, create a profile through the JustBrowser desktop app. Each profile stores:
- An identity configuration: canvas, WebGL, audio, fonts, screen dimensions, timezone, navigator properties
- Optional proxy: HTTP, HTTPS, or SOCKS5, configured per profile
- Persistent state: cookies, localStorage, IndexedDB, browsing history
- A unique profile ID for API calls
Keep the identity internally consistent: a Windows laptop identity with a matching GPU, screen and font set, in a time zone that matches the proxy's location. Mismatched parameters are the usual cause of odd rendering or locale behaviour in your own tests. The WebGL fingerprinting deep dive explains how those values relate.
Note your profile ID. You'll find it in the profile settings.
Step 2: Set Up Your C# Project
Create a new console app (or add to your existing project):
dotnet new console -n ProfileAutomation
cd ProfileAutomation
dotnet add package PuppeteerSharp
Here's the basic structure we'll build out:
using PuppeteerSharp;
using System.Net.Http.Json;
namespace ProfileAutomation;
public class Program
{
private static readonly HttpClient _http = new();
private const string ApiBase = "http://127.0.0.1:36542/api/v1";
public static async Task Main(string[] args)
{
// Token from the app's API/AI tab — every request needs it
_http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("JB_API_TOKEN"));
var profileId = "your-profile-id-here";
// Start, connect, automate, cleanup
var cdpUrl = await StartProfileAsync(profileId);
var browser = await ConnectBrowserAsync(cdpUrl);
try
{
await RunAutomationAsync(browser);
}
finally
{
browser.Disconnect(); // detaches PuppeteerSharp; the profile keeps running
await StopProfileAsync(profileId);
}
}
}
That's the skeleton. Nothing fancy. Now let's fill in the pieces.
Step 3: Start a Profile via REST API
JustBrowser's REST API runs locally when the app is open. Default port: 36542 (configurable in the app), with a Bearer token from the API/AI tab. Hit the start endpoint, get back a CDP WebSocket URL.
using System.Text.Json.Serialization;
// The API uses snake_case field names, so map them explicitly
public record StartData(
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("cdp_url")] string CdpUrl,
[property: JsonPropertyName("launched_at")] string LaunchedAt);
public record StartResponse([property: JsonPropertyName("data")] StartData Data);
public static async Task<string> StartProfileAsync(string profileId)
{
var startRequest = new
{
headless = false // the API starts headless unless told otherwise; this is the only field it accepts
};
var response = await _http.PostAsJsonAsync(
$"{ApiBase}/profiles/{profileId}/start",
startRequest
);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<StartResponse>();
return result?.Data.CdpUrl
?? throw new InvalidOperationException("No cdp_url returned");
}
What's happening: we POST to the start endpoint with optional config (headed vs headless), and the API returns {"data": {"profile_id", "status": "running", "cdp_url", "launched_at"}}. The cdp_url is a standard CDP endpoint — any CDP-aware tool can connect.
If you're getting connection refused, JustBrowser probably isn't running. The API only works when the desktop app is open.
Step 4: Connect PuppeteerSharp Over CDP
public static async Task<IBrowser> ConnectBrowserAsync(string cdpUrl)
{
// Give the browser a moment to fully initialize
await Task.Delay(2000);
var browser = await Puppeteer.ConnectAsync(new ConnectOptions
{
BrowserWSEndpoint = cdpUrl,
DefaultViewport = null // Use profile's configured viewport
});
return browser;
}
That's it. Seriously.
PuppeteerSharp connects to the already-running profile. All your existing code (page.GoToAsync, page.ClickAsync, page.TypeAsync, page.WaitForSelectorAsync) works exactly as before. No API changes, no new abstractions to learn, no wrapper libraries. Just a different browser on the other end of the WebSocket.
The profile's identity parameters are applied by the engine itself, so there is nothing to configure on the PuppeteerSharp side.
Step 5: Run Your Automation
public static async Task RunAutomationAsync(IBrowser browser)
{
var pages = await browser.PagesAsync();
var page = pages.FirstOrDefault() ?? await browser.NewPageAsync();
// Check the profile's identity values before your own workflow
await page.GoToAsync("https://browserleaks.com/canvas");
await Task.Delay(3000);
var canvasHash = await page.EvaluateFunctionAsync<string>(
"() => document.querySelector('.hash')?.textContent"
);
Console.WriteLine($"Canvas fingerprint: {canvasHash}");
// Now open the site you are working on
await page.GoToAsync("https://your-app.example.com");
// Your automation logic here
await page.WaitForSelectorAsync("#login-form");
await page.TypeAsync("#email", "[email protected]");
await page.TypeAsync("#password", "correcthorsebatterystaple");
await page.ClickAsync("#submit");
await page.WaitForNavigationAsync();
Console.WriteLine($"Current URL: {page.Url}");
}
Every launch uses the same profile identity. Canvas renders the same hash. WebGL reports the same GPU. Fonts enumerate in the same order.
That repeatability is useful for testing. A QA run on Tuesday sees the same browser configuration as the run on Monday, and a teammate who opens the shared profile sees the same cookies and storage you do.
Step 6: Clean Up
public static async Task StopProfileAsync(string profileId)
{
try
{
await _http.PostAsync($"{ApiBase}/profiles/{profileId}/stop", null);
}
catch (HttpRequestException)
{
// Profile might already be stopped — that's fine
}
}
Always stop profiles when done. Each profile is a full browser process, so leaving them running eats RAM fast, more so with heavy single-page apps. Use browser.Disconnect() to detach PuppeteerSharp rather than CloseAsync(), which sends a close command to the browser itself, and let the /stop call end the session.
Step 7: Scale With a Profile Pool
Running parallel automation? Each worker needs its own profile:
public class ProfilePool
{
private readonly Queue<string> _available;
private readonly Dictionary<string, IBrowser> _active = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly HttpClient _http;
private readonly string _apiBase;
public ProfilePool(IEnumerable<string> profileIds, string apiBase = "http://127.0.0.1:36542/api/v1")
{
_available = new Queue<string>(profileIds);
_apiBase = apiBase;
_http = new HttpClient();
_http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("JB_API_TOKEN"));
}
public async Task<(string ProfileId, IBrowser Browser)> AcquireAsync()
{
await _lock.WaitAsync();
try
{
if (_available.Count == 0)
throw new InvalidOperationException("No profiles available");
var profileId = _available.Dequeue();
var cdpUrl = await StartProfileInternalAsync(profileId);
await Task.Delay(2000);
var browser = await Puppeteer.ConnectAsync(new ConnectOptions
{
BrowserWSEndpoint = cdpUrl
});
_active[profileId] = browser;
return (profileId, browser);
}
finally
{
_lock.Release();
}
}
public async Task ReleaseAsync(string profileId)
{
await _lock.WaitAsync();
try
{
if (_active.TryGetValue(profileId, out var browser))
{
browser.Disconnect();
_active.Remove(profileId);
}
await _http.PostAsync($"{_apiBase}/profiles/{profileId}/stop", null);
_available.Enqueue(profileId);
}
finally
{
_lock.Release();
}
}
private async Task<string> StartProfileInternalAsync(string profileId)
{
var response = await _http.PostAsJsonAsync(
$"{_apiBase}/profiles/{profileId}/start",
new { headless = false }
);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<StartResponse>();
return result!.Data.CdpUrl;
}
}
Now your Parallel.ForEachAsync or Task.WhenAll can acquire profiles from the pool, run automation, release back. Each worker gets its own identity parameters, cookies, and browsing context.
If you're running 10+ parallel workers on a laptop, watch your memory. Each profile is a full browser instance, so budget for several gigabytes of browser processes. Remember that the desktop app must run on whichever Windows or macOS machine hosts the workers.
Common Errors and How to Fix Them
Error: PuppeteerException: Failed to connect to browser
Cause: The profile didn't finish launching before PuppeteerSharp tried connecting.
Fix: Increase the delay after launch. Or poll the WebSocket port until it responds:
using System.Net.Sockets;
public static async Task WaitForPortAsync(int port, int timeoutSeconds = 30)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
try
{
using var client = new TcpClient();
await client.ConnectAsync("localhost", port);
return;
}
catch (SocketException)
{
await Task.Delay(500);
}
}
throw new TimeoutException($"Port {port} not available after {timeoutSeconds}s");
}
Error: HttpRequestException: Connection refused
Cause: JustBrowser isn't running, or the API port has been changed from the default 36542 in the app's settings.
Fix: Launch JustBrowser. Open the API/AI tab and check the actual API port and token. Also check if something else is bound to 36542 — worth ruling out when you run several local services on the same machine.
Error: Identity values look wrong in a test page
Cause: The values a test page reports do not match what you configured, for example a time zone that disagrees with the proxy's location.
Fix: Open the profile manually in JustBrowser and look at the same page. If it looks right there but differs under PuppeteerSharp, look for code that creates a new browser context. Work in the pages the profile already has (browser.PagesAsync()) rather than calling CreateBrowserContextAsync(), which opens an incognito context outside the profile with none of its cookies or storage. Start the profile through the API and let the app manage its launch flags. The JA4 and TLS fingerprint walkthrough covers how the network-level values relate to the browser-level ones.
Error: The site behaves differently under automation than by hand
Cause: Sites respond to many signals, and a mismatched proxy location, a headless session, or very fast interaction can change what they serve.
Fix: Start with the proxy: its location should agree with the profile's time zone and language. Then check whether the session is headless. A headless session has no real window, so its screen and viewport follow headless defaults. Send {"headless": false} when you need the session to behave like a visible browser. If you automate your own site, check your rate limits and any test-traffic allowlist your platform offers. If you automate someone else's, read their terms first; a site that does not allow automated access is not one to automate. For email-related workflows, JustEmails handles the sending side.
Next Steps
You've got PuppeteerSharp talking to a persistent JustBrowser profile. Where to go from here:
Test before production. Run your flow against BrowserLeaks pages to confirm the profile reports the identity you configured: platform, time zone, language, screen, and no WebRTC leak of your real IP. Five minutes here saves days of debugging a mismatch later.
Match proxies to identities. A profile configured for Chicago shouldn't route through a German datacenter, because the time zone and language will disagree with the exit IP. Pick a proxy in the region the profile represents. The ISP proxy setup guide has the details.
Share profiles with your team. Team seats are free. A seat can run the profiles you share with it, so a colleague can open the same client profile without you handing over credentials in chat. Seats do not get the REST API, so keep automation on the owner's machine. For how agencies organise many client accounts this way, see managing 50+ client ad accounts.
Consider the integration pattern. For production workloads, you probably want JustBrowser left running on a dedicated Windows or macOS machine, profiles already created, and your .NET app connecting on demand. If the workflow feeds a site you run, JustAnalytics provides cookieless analytics for it. And if you run ad verification on your own campaigns, ClickzProtect detects bot traffic on them.
The 7-day trial is enough to validate the whole workflow — unlimited profiles and the REST API are in it from the start, so there's no feature you have to pay to go and check. After that it's $9.99/mo, or $99.99/year. The flat price means a bigger job never changes the bill.
Frequently Asked Questions
Can PuppeteerSharp connect to an already-running JustBrowser profile?
Yes. JustBrowser's local REST API returns a cdp_url when you start a profile. PuppeteerSharp's ConnectAsync method accepts that WebSocket URL directly, so you drive the profile with the PuppeteerSharp code you already have.
Why use C# and PuppeteerSharp instead of Node.js Puppeteer?
If your stack is already .NET (an ASP.NET Core backend, Azure Functions, an existing C# codebase), switching to Node.js for browser automation adds operational overhead. PuppeteerSharp is a mature .NET port with near-parity to the official Puppeteer API, so you automate in the language your team already uses.
What happens to cookies and profile state between PuppeteerSharp sessions?
JustBrowser profiles persist cookies, localStorage, IndexedDB, and browsing history across sessions. When you start a profile through the REST API and connect PuppeteerSharp, you are resuming that profile's existing state. Stop the profile and the data stays for the next launch.
Try JustBrowser
A multi-account browser for teams, built on a custom Chromium engine, not an extension. Real C++ engine patches at the canvas / WebGL / audio / font / screen layer, so each profile's 40+ identity parameters are consistent. REST API for Playwright, Puppeteer, Selenium. $9.99/month or $99.99/year. 7-day free trial, card required — cancel any time in the seven days and you are not charged. Unlimited profiles.
Ready to manage multiple accounts?
Seven days free, then $9.99/month — one plan, everything included.