⚡ Dual Login API

Drive your profiles from any language.

Automate a real, fingerprint-spoofed browser over plain HTTP or the Chrome DevTools Protocol — with Selenium, Puppeteer, Playwright, or raw CDP. Copy a sample, paste it, run it. ~5 minutes.

curl / HTTPSeleniumPuppeteer PlaywrightRaw CDP Python · Node · C# · Java · Go · PHP

Overview

Every Dual Login profile is a real browser with its own fingerprint, cookies and proxy. There are two ways to control one — pick whichever fits:

🖥️ Local — this PC
Talk to the app running on your machine at http://127.0.0.1:4482. No auth needed (localhost only). Best for scripts on the same computer.
🌐 Global — from anywhere
Talk to https://api.duallogin.com/globally with a bearer token; it relays to your PC's agent. Best for a website / server controlling your browser remotely.
💡 Two ways to test: paste a curl command straight into your terminal, or log in (Global) to get a token first. Both hit the exact same actions.

How it works

1
Launch a profile (in the app, or POST .../launch). It opens a real browser.
2
Send actions over HTTP (goto, click, type…) — or attach Selenium/Puppeteer/Playwright over the profile's CDP endpoint.
3
Read results back (get, eval, screenshot, cookies).

Local vs Global

The action names are identical in both modes — only the base URL and how you target the profile differ.

# base = http://127.0.0.1:4482 — profile in the PATH
curl http://127.0.0.1:4482/api/profiles

curl -X POST http://127.0.0.1:4482/api/profiles/PROFILE_ID/goto \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com"}'
# 1) log in -> token
curl -X POST https://api.duallogin.com/globally/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"****"}'
# => {"token":"eyJ..."}

# 2) call actions — profile in the BODY + bearer token
curl -X POST https://api.duallogin.com/globally/goto \
  -H 'Authorization: Bearer eyJ...' -H 'Content-Type: application/json' \
  -d '{"profile":"PROFILE_ID","url":"https://example.com"}'
profile can be the profile's id or its login email. Tabs are 1-based. Selectors are CSS, or XPath if they start with //.

Profiles & status

Read-only lookups — list profiles, see what's running, grab a profile's CDP endpoint.

MethodEndpoint (local)Returns
GET/api/profilesAll profiles (id, name, fingerprint, proxy)
GET/api/runningProfiles currently open
GET/api/profiles/{id}One profile
GET/api/profiles/{id}/tabsOpen tabs (number · title · URL)
GET/api/profiles/{id}/cookiesLive cookies
GET/api/profiles/{id}/cdpCDP port + URLs for Selenium/Puppeteer/Playwright
Global equivalents: GET /globally/profiles, /globally/running, /globally/status (is your PC agent connected?).

Profile control

Drive a launched profile. Local: POST /api/profiles/{id}/<action>. Global: POST /globally/<action> with {profile} in the body.

ActionBodyWhat it does
launch{}Open the profile's browser
stop{}Close it
goto{tab,url}Navigate
click{tab,selector|text|xpath|x,y}Real mouse click (auto-searches iframes)
type{tab,selector,text,delay}Type into a field
set-value{tab,selector,value}Clear & set a field value
press{tab,key}Press a key / chord (Enter, Tab…)
hover · scroll · select{tab,selector,…}Hover / scroll-to / pick a <select> option
wait{tab,selector,timeout} or {ms}Wait for an element / sleep
get{tab,selector,attribute}Read text / value / html / attribute
eval{tab,expression}Run JS, get the result
screenshot{tab,fullPage}Screenshot (base64 + data URL)
new-tab · close-tab{url} / {tab}Open / close a tab
reload · back · forward{tab}History controls

Update fingerprint

Create a profile with a fresh identity, tweak its fingerprint, or generate one to preview.

MethodEndpointBody / query
POST/api/profiles{name, os, country, proxy, startUrl} → new profile + auto fingerprint
PATCH/api/profiles/{id}{fingerprint:{os,timezone,languages,...}} → update fields
GET/api/fingerprint/generate?os=Windows&country=US → a ready-to-use fingerprint
# new profile with a US Windows identity behind a proxy
curl -X POST http://127.0.0.1:4482/api/profiles -H 'Content-Type: application/json' -d '{
  "name":"US-01","os":"Windows","country":"US",
  "proxy":{"server":"http://host:port","username":"u","password":"p"}
}'

# change its timezone + language
curl -X PATCH http://127.0.0.1:4482/api/profiles/PROFILE_ID -H 'Content-Type: application/json' \
  -d '{"fingerprint":{"timezone":"America/New_York","languages":["en-US","en"]}}'

Sample · curl / HTTP

No driver needed — just HTTP. Launch a profile, open a page, wait, and read the heading. Replace PROFILE_ID.

import requests

BASE = "http://127.0.0.1:4482"
PID  = "PROFILE_ID"

def act(name, **body):
    r = requests.post(f"{BASE}/api/profiles/{PID}/{name}", json=body)
    r.raise_for_status()
    return r.json()

requests.post(f"{BASE}/api/profiles/{PID}/launch", json={})
act("goto", tab=1, url="https://example.com")
act("wait", tab=1, selector="h1", timeout=8000)
print("Heading:", act("get", tab=1, selector="h1"))
const BASE = "http://127.0.0.1:4482";
const PID  = "PROFILE_ID";

const act = (name, body = {}) =>
  fetch(`${BASE}/api/profiles/${PID}/${name}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  }).then((r) => r.json());

await act("launch");
await act("goto", { tab: 1, url: "https://example.com" });
await act("wait", { tab: 1, selector: "h1", timeout: 8000 });
console.log("Heading:", await act("get", { tab: 1, selector: "h1" }));
using System.Net.Http;
using System.Text;

var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:4482") };
const string PID = "PROFILE_ID";

async Task<string> Act(string name, string json = "{}") {
    var res = await http.PostAsync($"/api/profiles/{PID}/{name}",
        new StringContent(json, Encoding.UTF8, "application/json"));
    return await res.Content.ReadAsStringAsync();
}

await Act("launch");
await Act("goto", "{\"tab\":1,\"url\":\"https://example.com\"}");
await Act("wait", "{\"tab\":1,\"selector\":\"h1\",\"timeout\":8000}");
Console.WriteLine(await Act("get", "{\"tab\":1,\"selector\":\"h1\"}"));
package main

import ("bytes"; "fmt"; "io"; "net/http")

const base, pid = "http://127.0.0.1:4482", "PROFILE_ID"

func act(name, body string) string {
    url := fmt.Sprintf("%s/api/profiles/%s/%s", base, pid, name)
    r, _ := http.Post(url, "application/json", bytes.NewBufferString(body))
    defer r.Body.Close()
    b, _ := io.ReadAll(r.Body)
    return string(b)
}

func main() {
    act("launch", "{}")
    act("goto", `{"tab":1,"url":"https://example.com"}`)
    act("wait", `{"tab":1,"selector":"h1","timeout":8000}`)
    fmt.Println(act("get", `{"tab":1,"selector":"h1"}`))
}
<?php
$base = "http://127.0.0.1:4482"; $pid = "PROFILE_ID";

function act($name, $body = []) {
    global $base, $pid;
    $ch = curl_init("$base/api/profiles/$pid/$name");
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
        CURLOPT_POSTFIELDS => json_encode((object)$body),
        CURLOPT_RETURNTRANSFER => true,
    ]);
    return curl_exec($ch);
}

act("launch");
act("goto", ["tab" => 1, "url" => "https://example.com"]);
act("wait", ["tab" => 1, "selector" => "h1", "timeout" => 8000]);
echo act("get", ["tab" => 1, "selector" => "h1"]);
🌐 Global version: swap the URL to https://api.duallogin.com/globally/<action>, add Authorization: Bearer <token>, and put "profile":"PROFILE_ID" in the body.

Sample · Puppeteer

Attach Puppeteer to a running profile over its CDP endpoint (from GET /api/profiles/{id}/cdp).

import puppeteer from "puppeteer-core";

const PID = "PROFILE_ID";
// make sure it's running: POST /api/profiles/PID/launch
const cdp = await fetch(`http://127.0.0.1:4482/api/profiles/${PID}/cdp`).then(r => r.json());
// cdp = { port, httpEndpoint, puppeteer, playwright, selenium }

const browser = await puppeteer.connect({ browserURL: cdp.httpEndpoint });
const page = (await browser.pages())[0] ?? await browser.newPage();

await page.goto("https://example.com", { waitUntil: "domcontentloaded" });
console.log("Title:", await page.title());

await browser.disconnect(); // leaves the profile open

Sample · Playwright

Connect over CDP with connectOverCDP — Node or Python.

import { chromium } from "playwright";

const PID = "PROFILE_ID";
const cdp = await fetch(`http://127.0.0.1:4482/api/profiles/${PID}/cdp`).then(r => r.json());

const browser = await chromium.connectOverCDP(cdp.httpEndpoint);
const ctx  = browser.contexts()[0];
const page = ctx.pages()[0] ?? await ctx.newPage();

await page.goto("https://example.com");
console.log("Title:", await page.title());
await browser.close();
import requests
from playwright.sync_api import sync_playwright

PID = "PROFILE_ID"
cdp = requests.get(f"http://127.0.0.1:4482/api/profiles/{PID}/cdp").json()

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(cdp["httpEndpoint"])
    ctx  = browser.contexts[0]
    page = ctx.pages[0] if ctx.pages else ctx.new_page()
    page.goto("https://example.com")
    print("Title:", page.title())
    browser.close()

Sample · Selenium

Attach Selenium to the running profile via its debugger address (the port from /cdp).

import requests
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

PID = "PROFILE_ID"
cdp = requests.get(f"http://127.0.0.1:4482/api/profiles/{PID}/cdp").json()

opts = Options()
opts.add_experimental_option("debuggerAddress", f"127.0.0.1:{cdp['port']}")
driver = webdriver.Chrome(options=opts)   # attaches to the live profile

driver.get("https://example.com")
print("Title:", driver.title)
// GET http://127.0.0.1:4482/api/profiles/PROFILE_ID/cdp -> { "port": 51234 }
ChromeOptions options = new ChromeOptions();
options.setExperimentalOption("debuggerAddress", "127.0.0.1:51234");

WebDriver driver = new ChromeDriver(options);
driver.get("https://example.com");
System.out.println("Title: " + driver.getTitle());
// GET http://127.0.0.1:4482/api/profiles/PROFILE_ID/cdp -> { "port": 51234 }
var options = new OpenQA.Selenium.Chrome.ChromeOptions();
options.DebuggerAddress = "127.0.0.1:51234";

var driver = new OpenQA.Selenium.Chrome.ChromeDriver(options);
driver.Navigate().GoToUrl("https://example.com");
System.Console.WriteLine("Title: " + driver.Title);

Sample · Raw CDP websocket

Lowest level — open the DevTools websocket and send CDP commands yourself. Get the browser ws URL from http://127.0.0.1:{port}/json/version.

import WebSocket from "ws";

const PID = "PROFILE_ID";
const { port } = await fetch(`http://127.0.0.1:4482/api/profiles/${PID}/cdp`).then(r => r.json());
const { webSocketDebuggerUrl } = await fetch(`http://127.0.0.1:${port}/json/version`).then(r => r.json());

const ws = new WebSocket(webSocketDebuggerUrl);
let id = 0;
const send = (method, params = {}) => ws.send(JSON.stringify({ id: ++id, method, params }));

ws.on("open", () => send("Target.createTarget", { url: "https://example.com" }));
ws.on("message", (m) => console.log(m.toString()));
# pip install websocket-client requests
import json, requests, websocket

PID = "PROFILE_ID"
port = requests.get(f"http://127.0.0.1:4482/api/profiles/{PID}/cdp").json()["port"]
ws_url = requests.get(f"http://127.0.0.1:{port}/json/version").json()["webSocketDebuggerUrl"]

ws = websocket.create_connection(ws_url)
ws.send(json.dumps({"id": 1, "method": "Target.createTarget",
                    "params": {"url": "https://example.com"}}))
print(ws.recv())
ws.close()
// go get github.com/gorilla/websocket
package main

import ("encoding/json"; "fmt"; "net/http"; "github.com/gorilla/websocket")

func main() {
    var cdp struct{ Port int `json:"port"` }
    r, _ := http.Get("http://127.0.0.1:4482/api/profiles/PROFILE_ID/cdp")
    json.NewDecoder(r.Body).Decode(&cdp)

    var ver struct{ WS string `json:"webSocketDebuggerUrl"` }
    r2, _ := http.Get(fmt.Sprintf("http://127.0.0.1:%d/json/version", cdp.Port))
    json.NewDecoder(r2.Body).Decode(&ver)

    c, _, _ := websocket.DefaultDialer.Dial(ver.WS, nil)
    defer c.Close()
    c.WriteJSON(map[string]any{"id": 1, "method": "Target.createTarget",
        "params": map[string]string{"url": "https://example.com"}})
    _, msg, _ := c.ReadMessage()
    fmt.Println(string(msg))
}
⚠️ CDP attach (Selenium/Puppeteer/Playwright/raw) needs the profile launched in CDP mode (turn off "Stealth login" for that profile). Pure-stealth profiles have no CDP port.
Dual Login API · drive real browsers from any language. Questions? Help center