Bulk-Creating Profiles From Fingerprint Templates: A CSV-to-API Workflow
Picture needing 200 browser profiles. Not twenty. Not fifty. Two hundred, each with its own name, locale and proxy, each configured for a different region or client, each ready to run in a Playwright automation pipeline by Monday morning.
The app's Bulk create makes up to 100 profiles at once from a naming pattern, which is fine when every profile has the same shape. When each one needs its own locale, template and proxy, clicking through 200 of them adds up to hours.
Not a good use of a morning.
The local REST API exists for this. With a CSV, a few saved templates and a short Python script, writing the CSV takes longer than creating the profiles.
One thing to know up front: JustBrowser has no CSV importer. The CSV in this tutorial is your own file, and the script turns each row into API calls. That keeps the workflow plain and lets you version the file alongside your automation code.
By the end, you'll have a repeatable process for bulk profile provisioning that you can run whenever you need a new set of profiles.
What We're Building
A complete pipeline that:
- Reads profile settings from a CSV file (one row per profile)
- Validates each row before sending
- Creates profiles via the JustBrowser REST API, applies a template and attaches a proxy
- Outputs a manifest of created profile IDs for downstream automation
The output: a JSON file mapping your CSV identifiers to JustBrowser profile IDs. Feed that into your Playwright/Puppeteer/Selenium scripts and you're running.
Prerequisites
- JustBrowser ($9.99/mo, or $99.99/year): the local REST API is part of the plan, and the seven-day trial includes it too
- Python 3.9+ with
requestsinstalled - JustBrowser running locally: the API listens on 127.0.0.1:36542 by default (configurable in the app)
- An API token from the app's API/AI tab: every request sends it as a Bearer token in the
Authorizationheader - A CSV file with your profile settings (we'll cover the format)
If you haven't used the API yet, the API docs list every endpoint used here. This tutorial assumes you can already authenticate and call GET /profiles.
Step 1: Understand What a Template Is
When you create a profile, the engine generates its fingerprint from population data on real devices: screen, fonts, WebGL, hardware values and the rest of the 40+ identity parameters. You steer it with a few choices (the operating system of the identity, its locale), and the engine fills in a combination that ships together on real hardware.
A template is a saved fingerprint shape. Configure one profile the way you want it in the app, then save its fingerprint as a template (in the app, or with POST /profiles/{id}/template/save). GET /templates lists your saved templates alongside the built-in presets. Applying a template to a new profile with POST /profiles/{id}/template gives it the template's shape (OS, screen, locale, time zone and so on) with its own noise, so two profiles from one template are alike in shape but not identical.
This is also why you don't hand-write WebGL renderer strings or user agents in a CSV. A made-up GPU name next to a user agent that doesn't match it is the most common way to build an inconsistent profile, and the generator removes that step entirely. The WebGL fingerprinting deep dive explains why the renderer string matters. (Some browser signals aren't profile settings at all; battery, sensor and pointer APIs are one example.)
Step 2: Design Your CSV
One row per profile. The columns map to what the API accepts:
profile_name,os,locale,template
us-east-001,windows,en-US,US Windows desktop
us-east-002,windows,en-US,US Windows desktop
eu-west-001,macos,en-GB,UK Mac laptop
profile_name: up to 100 charactersos:windowsormacosfor the identity (the default is windows)locale: the identity's language and region, such asde-DE(the default is en-US)template: optional, the name of a saved template or preset
Template names can repeat, because every save makes a new template. Give yours unique names, or the script below can't tell them apart.
(Don't try to pin everything. Choose the OS, locale and template your use case needs and let the engine handle the rest.)
Step 3: Write the Profile Creation Script
Here's the core script. It reads your CSV, validates each row, creates the profiles, applies templates and writes a manifest:
import csv
import json
import os
import sys
import time
import requests
API_BASE = "http://127.0.0.1:36542/api/v1"
API_TOKEN = os.environ["JUSTBROWSER_TOKEN"] # from the app's API/AI tab
HEADERS = {"Authorization": f"Bearer {API_TOKEN}"}
def api(method, path, **kwargs):
"""Call the local API and return its data, or raise with the error code."""
response = requests.request(method, f"{API_BASE}{path}", headers=HEADERS, timeout=60, **kwargs)
body = response.json()
if not response.ok:
error = body.get("error", {})
# Branch on error.code, never on the message
raise RuntimeError(f"{method} {path} failed: {error.get('code')} {error.get('details', '')}")
return body["data"]
def template_ids_by_name():
data = api("GET", "/templates")
return {t["name"]: t["id"] for t in data["templates"] + data["presets"]}
def validate_row(row, row_num, templates):
"""Check required fields and that any named template exists."""
missing = [f for f in ("profile_name", "os", "locale") if not row.get(f)]
if missing:
print(f"Row {row_num}: missing fields {missing}")
return False
if row.get("template") and row["template"] not in templates:
print(f"Row {row_num}: no template named {row['template']!r}")
return False
return True
def create_profile(row, templates):
"""Create one profile, then apply its template if the row names one."""
profile = api("POST", "/profiles", json={
"name": row["profile_name"],
"os": row["os"],
"locale": row["locale"],
})
profile_id = profile["id"]
if row.get("template"):
api("POST", f"/profiles/{profile_id}/template",
json={"template_id": templates[row["template"]]})
return profile_id
def bulk_create_profiles(csv_path, output_path="profile_manifest.json"):
with open(csv_path, newline="") as f:
rows = list(csv.DictReader(f))
templates = template_ids_by_name()
manifest, failed = {}, 0
print(f"Found {len(rows)} profiles to create")
for i, row in enumerate(rows, 1):
if not validate_row(row, i, templates):
failed += 1
continue
try:
profile_id = create_profile(row, templates)
except RuntimeError as exc:
print(f"[{i}/{len(rows)}] {exc}")
failed += 1
continue
manifest[row["profile_name"]] = profile_id
print(f"[{i}/{len(rows)}] Created: {row['profile_name']} -> {profile_id}")
time.sleep(0.1) # small pause between writes
with open(output_path, "w") as f:
json.dump(manifest, f, indent=2)
print(f"\nDone. Created: {len(manifest)}, Failed: {failed}")
print(f"Manifest written to: {output_path}")
return manifest
if __name__ == "__main__":
csv_file = sys.argv[1] if len(sys.argv) > 1 else "profiles.csv"
out_file = sys.argv[2] if len(sys.argv) > 2 else "profile_manifest.json"
bulk_create_profiles(csv_file, out_file)
Save this as bulk_create.py. Run it with:
python bulk_create.py my_profiles.csv
The manifest maps your names to profile IDs:
{
"us-east-001": "<profile id>",
"us-east-002": "<profile id>",
"eu-west-001": "<profile id>"
}
That manifest is your bridge to automation. Your Playwright/Puppeteer scripts read it, start the corresponding profile and run.
Step 4: Add Proxies
Most bulk setups pin each profile to a proxy: an ISP proxy, a residential one, whatever matches your use case. These come from your own provider or a proxy plan bought in the app.
Extend the CSV:
profile_name,os,locale,template,proxy_protocol,proxy_host,proxy_port,proxy_user,proxy_pass
us-east-001,windows,en-US,US Windows desktop,http,us-east.proxy.example.com,8080,user1,pass1
Proxies are their own objects in the API. Create one with POST /proxies, assign it with PUT /profiles/{id}/proxy, test it, then ask the profile to match its time zone and language to where the proxy exits:
def add_proxy(row):
"""Create a proxy from the row. Credentials are encrypted on write."""
payload = {
"name": f"{row['profile_name']}-proxy",
"protocol": row.get("proxy_protocol") or "http", # http, https or socks5
"host": row["proxy_host"],
"port": int(row["proxy_port"]),
}
if row.get("proxy_user"):
payload["username"] = row["proxy_user"]
payload["password"] = row.get("proxy_pass", "")
return api("POST", "/proxies", json=payload)["id"]
def attach_proxy(profile_id, proxy_id):
api("PUT", f"/profiles/{profile_id}/proxy", json={"proxy_id": proxy_id})
test = api("POST", f"/proxies/{proxy_id}/test") # a failed test is an answer, not an error
if not test.get("success"):
print(f"Proxy {proxy_id} failed its test: {test.get('errorCode')}")
return False
match = api("POST", f"/profiles/{profile_id}/geo-match",
json={"proxy_id": proxy_id, "apply": True})
if not match["applied"]:
print(f"Geo-match not applied for {profile_id}: {match.get('skippedReason', 'no changes needed')}")
return True
Call both from create_profile when the row has a proxy_host. The proxy applies at the profile's next launch, and the geo-match step means a profile behind a German exit gets a German time zone and language instead of whatever the template carried. Geo-match is never applied when two location lookups disagree on the country, which is the case you want to look at by hand.
When proxies change later, PUT /profiles/{id}/proxy swaps one profile's proxy, and POST /profiles/batch/proxy spreads a pool of proxies across many profiles in one call (round-robin by default, or random).
For proxy sourcing, residential vs ISP vs datacenter and when each makes sense, the ISP proxy setup tutorial has the breakdown.
When every profile has the same shape: POST /profiles/bulk
If a batch shares one template, one proxy and one locale, you don't need a row per profile. POST /profiles/bulk creates up to 100 profiles in one call from a naming pattern, where {n} becomes the number:
{
"count": 50,
"naming_pattern": "uk-verify-{n}",
"template_id": "<template id>",
"proxy_id": "<proxy id>"
}
With a proxy_id, each profile gets that proxy, plus the time zone and language of where it exits (from the proxy's last successful test). The answer has one line per profile; a profile that couldn't be made is a failed line, and the call itself still succeeds, so check failureCount.
Step 5: Template Variations for Different Use Cases
One CSV layout doesn't fit every job. A few that work:
Multi-region ad verification:
profile_name,os,locale,template,proxy_host,proxy_port
uk-verify-001,windows,en-GB,EU Windows desktop,uk.proxy.example.com,8080
de-verify-001,windows,de-DE,EU Windows desktop,de.proxy.example.com,8080
fr-verify-001,macos,fr-FR,EU Mac laptop,fr.proxy.example.com,8080
E-commerce storefront profiles (one per brand or team member you are authorised to run):
profile_name,os,locale,template
shop-brand-a,windows,en-US,US Windows desktop
shop-brand-b,macos,en-US,US Mac laptop
shop-brand-c,windows,en-US,US Windows desktop
Social media management for client accounts you are authorised to run:
profile_name,os,locale,template
social-client-a,windows,en-US,US Windows desktop
social-client-b,macos,en-GB,UK Mac laptop
The key: each layout carries only what that job needs. Ad verification cares about location, so it carries proxies. Storefront and client profiles care about staying separate and recognisable, so they carry clear names and a template per team standard.
One opinion, and some people disagree: start with fewer templates, not more. It's tempting to build one per use case on day one, but every template is something to maintain. If you later need to change a whole set of profiles, a small number of well-named templates is far easier to reason about.
Step 6: Validate the Profiles You Created
You've got 200 profiles. Are they internally consistent? Don't assume; check. GET /profiles/{id}/fingerprint/validate runs the engine's consistency rules and returns a 0-100 score plus any failing rules, each with a suggestion and whether it can be fixed automatically:
def validate_manifest(manifest_path):
with open(manifest_path) as f:
manifest = json.load(f)
report = {}
for name, profile_id in manifest.items():
result = api("GET", f"/profiles/{profile_id}/fingerprint/validate")
report[name] = {
"score": result["score"],
"issues": [(i["severity"], i["message"], i.get("fixable", False)) for i in result["issues"]],
}
print(f"{name}: {result['score']} ({len(result['issues'])} issue(s))")
return report
For rules marked fixable, POST /profiles/{id}/fingerprint/fix applies the fix. Anything else, read the suggestion and adjust the template or the row. For what a site can read below the page, at the network layer, which no profile setting changes, see the JA4 and TLS fingerprints explainer.
Common Errors and How to Fix Them
Reruns create duplicate profiles
Cause: The script creates a new profile for every row each time it runs.
Fix: Look the name up first and skip rows that already exist. GET /profiles takes a search parameter that matches part of a profile's name:
def find_profile(name):
data = api("GET", "/profiles", params={"search": name})
return next((p["id"] for p in data["profiles"] if p["name"] == name), None)
VALIDATION_ERROR on creation
Cause: The row has a value the API doesn't accept: an os outside its list, a name over 100 characters, a port that isn't a number.
Fix: Print error.details. For VALIDATION_ERROR it is an array of issues, one per bad field. Fix the row and rerun.
A template won't apply
Cause: Templates can't be applied while the profile is open.
Fix: Apply the template before the first launch, as the script does, or stop the profile with POST /profiles/{id}/stop first.
Profiles disagree with their proxy's location
Cause: The proxy was never tested, or the geo-match step was skipped because two location lookups disagreed on the country.
Fix: Run POST /proxies/{id}/test, then geo-match again, and look at any proxy whose country lookups conflict before you use it. The audio, font and hardware fingerprinting guide covers why locale, time zone and the rest need to tell one story.
Errors when creating profiles too fast
Cause: Hammering the local API in a tight loop.
Fix: Keep a time.sleep(0.1) or time.sleep(0.2) between calls; the script above includes one. If you still see failures, back off to 0.5 seconds, or use POST /profiles/bulk for same-shape batches. For 500 profiles, a 0.5-second pause costs about four minutes in total. Not elegant, but dependable.
Manifest file gets overwritten
Cause: You ran the script twice with different CSVs but the same output file.
Fix: Pass the output path as the second argument:
python bulk_create.py campaign_a.csv manifest_campaign_a.json
python bulk_create.py campaign_b.csv manifest_campaign_b.json
Next Steps
You've got bulk profile creation working. Here's where to go from here:
Integrate with your automation framework. The manifest maps names to profile IDs. Load it in your Playwright/Puppeteer script, start each profile, attach over CDP and run your task. The Playwright/Puppeteer integration guide shows the connection pattern.
Set up shared access. Decide which teammates need to run which profiles, then share them from the app. Team seats are free, and a seat can run only the profiles shared with it.
Generate the CSV. Instead of writing rows by hand, have a script produce them from your test matrix: which markets, which identity OS, which template. Change the matrix, regenerate the CSV, rerun.
Track profile health over time. Proxies expire and settings drift. A periodic job that tests each profile's proxy (POST /proxies/{id}/test) and re-runs the consistency check catches a profile whose time zone, locale or IP location no longer agree.
If you're running ad campaigns alongside your automation, ClickzProtect can help catch fraudulent traffic on the ad side. And for analytics on your own sites, JustAnalytics offers cookieless, privacy-first tracking.
The seven-day trial is full access, REST API included: enough to run this whole workflow end to end before the first charge lands. After that it's $9.99/mo, or $99.99/year, for unlimited profiles. There's no bulk-operations tier above it to upgrade into.
FAQ
Can I create profiles in bulk without using the GUI?
Yes. The local REST API creates profiles one at a time with POST /profiles, or up to 100 at once from a naming pattern with POST /profiles/bulk, and you can script either from any language that makes HTTP requests. There is no CSV importer, so a CSV workflow means your own script reading the file and calling the API. The API is part of the single plan and is included in the trial.
What should my CSV template include?
Only what the API takes when you create and set up a profile: a name, the identity's operating system and locale, optionally a saved template to apply, and proxy details. The engine generates the rest of the 40+ identity parameters from population data on real devices, so you don't hand-write WebGL strings or user agents.
How many profiles can I create per API call?
One per call with POST /profiles, which suits rows that each need their own settings. POST /profiles/bulk creates up to 100 per call from a naming pattern, optionally with a template and a proxy, which suits a batch of profiles with the same shape.
Do API-created profiles get the same fingerprints as GUI-created ones?
Yes. The API creates profiles the way the app does, and the app's Bulk create is the same operation as POST /profiles/bulk. Either way the engine generates the fingerprint, steered by the OS, locale or template you choose, and GET /profiles/{id}/fingerprint/validate reports a 0-100 consistency score and any failing rules.
Try JustBrowser
A multi-account browser for teams, on a custom Chromium engine, not extension-based. Real C++ engine patches at the canvas / WebGL / audio / font / screen layer, across 40+ identity parameters. Local REST API + CDP for Playwright, Puppeteer, Selenium. Windows x64 and macOS (Apple Silicon). $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.
Get started → · How it differs from Multilogin / GoLogin / AdsPower
Ready to manage multiple accounts?
Seven days free, then $9.99/month — one plan, everything included.