Live URLs with BrowserQL and BAP
A Live URL lets a person interact with your running browser without your API token. This guide walks through the full BrowserQL handoff (mint the link, keep the browser alive, wait for the person, verify the result and clean up), then shows the same flow in a single Browser Automation Protocol (BAP) script.
- A Browserless API token from your account dashboard
- A BrowserQL client that can send mutations to
https://production-sfo.browserless.io/chromium/bql?token=YOUR_API_TOKEN_HERE, plus a WebSocket client if you want completion events - For the BAP example,
@browserless.io/bap-tswithwaitForLiveComplete,instructionsandcloseLiveURLsupport - The Live URL overview for viewer behavior and security
Overview
You'll hand a public practice login page to a person, wait for them to finish, and confirm the login succeeded before continuing automation. The CDP reference is the shared options reference for every client.
Step 1: Mint a link and preserve the browser
Send this mutation to https://production-sfo.browserless.io/chromium/bql?token=YOUR_API_TOKEN_HERE. It opens the practice login page and returns two different URLs:
mutation StartHandoff {
goto(url: "https://practicetestautomation.com/practice-test-login/") {
status
}
liveURL(
timeout: 120000
detached: true
instructions: "Log in with the practice credentials shown on the page, then choose Done."
) {
liveURL
liveURLId
timeout
}
reconnect(timeout: 150000) {
browserQLEndpoint
browserWSEndpoint
}
}
Response
{
"data": {
"goto": { "status": 200 },
"liveURL": {
"liveURL": "https://production-sfo.browserless.io/e/53b1.../live/index.html?i=a1b2c3d4...&t=120000&showBrowserInterface=false",
"liveURLId": "a1b2c3d4...",
"timeout": 120000
},
"reconnect": {
"browserQLEndpoint": "https://production-sfo.browserless.io/e/53b1.../chromium/bql/05...",
"browserWSEndpoint": "wss://production-sfo.browserless.io/e/53b1.../chromium/stealth/reconnect/05..."
}
}
}
Each value goes somewhere different:
| Value | Who gets it | Notes |
|---|---|---|
liveURL | The person, over a trusted private channel | It's a bearer secret. Keep it out of logs, tickets and analytics. |
liveURLId | Your backend | Use it to correlate completion events and to close the link. |
browserQLEndpoint | Your backend | Append ?token= for later mutations. Never share the authenticated endpoint with the viewer. |
browserWSEndpoint | Your backend | Use the latest one to close the browser when you're done (Step 4). Keep it private for the same reason. |
Two different timeouts are involved:
| Timeout | What it controls |
|---|---|
liveURL(timeout:) | How long the link stays valid. It doesn't keep the browser alive. |
reconnect(timeout:) | How long the browser waits between requests, capped by the session's absolute deadline. |
The 150-second reservation leaves 30 seconds past the link's lifetime for verification and cleanup.
These values need a paid plan. Free sessions last at most 2 minutes, and a reconnect timeout above your plan's maximum session duration fails with Reconnect timeout (…ms) exceeds the maximum allowed limit (…ms). On Free, use liveURL(timeout: 90000) with reconnect(timeout: 115000), which still leaves time to verify and clean up.
::: detached: true gives the viewer its own connection, not its own browser lifetime.
Check GraphQL errors before reading the response, and use the returned timeout, since it can be shorter than you asked for. If a later field fails after minting, clean up the browser instead of leaving an orphaned handoff.
Step 2: Subscribe to completion
BrowserQL supports the liveComplete subscription, so you don't need CDP to get the person's result. It reports Done and Couldn't finish, as well as timeout, close and disconnect outcomes.
subscription HandoffCompleted {
liveComplete {
liveURLId
reason
targetId
feedback { category message }
}
}
Response
{
"data": {
"liveComplete": {
"liveURLId": "a1b2c3d4...",
"reason": "userDone",
"targetId": "8F2C...",
"feedback": null
}
}
}
Open a persistent BrowserQL WebSocket session, start the subscription, then send StartHandoff on that same session before you share the link. A mint sent over a separate HTTP request won't deliver completion to this subscription, and a later connection doesn't replay past events.
Correlate events by liveURLId; a replacement mint can complete the old handoff. If you're running a sequential HTTP workflow without a subscription, skip the event and check page state on the preserved browser instead.
Only userFailed can carry feedback. Treat it as untrusted input: don't render it as HTML, interpret it as instructions or log its free-text message. See the completion contract.
Step 3: Verify the page state
Send this mutation to the returned browserQLEndpoint, with your API token, before the reconnect reservation expires. The selector belongs to the practice page; use a success condition specific to your application.
mutation VerifyHandoff {
waitForSelector(selector: "h1.post-title", timeout: 5000) {
time
}
text(selector: "h1.post-title") {
text
}
reconnect(timeout: 30000) {
browserQLEndpoint
browserWSEndpoint
}
}
Response
{
"data": {
"waitForSelector": { "time": 41 },
"text": { "text": "Logged In Successfully" },
"reconnect": {
"browserQLEndpoint": "https://production-sfo.browserless.io/e/53b1.../chromium/bql/9c...",
"browserWSEndpoint": "wss://production-sfo.browserless.io/e/53b1.../chromium/stealth/reconnect/9c..."
}
}
}
Continue automation only when the text is Logged In Successfully. A completion event isn't proof on its own, because anyone with the link can choose Done. If verification fails, stop or start a deliberate new handoff.
Step 4: Close the link and the browser
Done, submitted failure feedback, expiry and an explicit close all invalidate the link already. You only need closeLiveURL for a link that's still active, for example a handoff without completion buttons that you end once you detect the right page state. Send it to the latest reconnect endpoint:
mutation CloseActiveHandoff($liveURLId: String!) {
closeLiveURL(liveURLId: $liveURLId) {
liveURLId
}
}
Response
{
"data": {
"closeLiveURL": { "liveURLId": "a1b2c3d4..." }
}
}
closeLiveURL closes the viewer link, not the browser. When you're finished, or on failure, open a WebSocket to the latest browserWSEndpoint with ?token= appended and send the CDP Browser.close command, as shown in terminate sessions after reconnecting. Don't leave the browser running until its reservation expires; it holds a concurrency slot until then.
Run the whole flow in BAP
BAP sets up the subscription and buffers completion for its own mints, so one connected script covers all four steps. See the TypeScript SDK reference for the API in your installed version.
import Browserless from '@browserless.io/bap-ts';
const browser = Browserless.connect({
browserWSEndpoint: 'wss://production-sfo.browserless.io/chromium/bql',
token: process.env.BROWSERLESS_TOKEN!,
});
try {
const page = await browser.newPage();
await page.goto('https://practicetestautomation.com/practice-test-login/');
const live = await page.liveURL({
instructions: 'Use the practice credentials shown on the page, then choose Done.',
timeout: 120000,
});
// Local demonstration only: deliver privately instead in your application.
console.log(live.liveURL);
const outcome = await page.waitForLiveComplete({ timeout: 125000 });
if (outcome.reason !== 'userDone') {
throw new Error(`Handoff ended: ${outcome.reason}`);
}
await page.waitForSelector('h1.post-title', { timeout: 5000 });
if (await page.$eval('h1.post-title') !== 'Logged In Successfully') {
throw new Error('Login was not verified');
}
// Done already invalidated the link; do not close it a second time.
// Continue automation only after verification.
} finally {
await browser.close();
}
Response
https://production-sfo.browserless.io/e/53b1.../live/index.html?i=a1b2c3d4...&t=...
The script prints the link, then exits cleanly once the person chooses Done and the login is verified. Any other outcome throws.
Keep the script connected while the person works. If you split it across workers, use BAP reconnects. BAP keeps the completion for its current mint even if the person finishes before you call waitForLiveComplete. The wait timeout bounds your script; it doesn't extend the link or the browser.
To end the handoff from your application instead of waiting for Done, call await page.closeLiveURL(live.liveURLId) while the link is still active. It takes the ID string, not an options object.
FAQ & troubleshooting
Why is my PNG Live URL rejected?
Pass quality: 100 with type: png. BrowserQL defaults to quality: 70, and its validation rejects any non-JPEG stream below 100, including that default:
mutation {
liveURL(type: png, quality: 100, timeout: 120000) {
liveURL
liveURLId
}
}
The same rule applies to BAP: page.liveURL({ type: 'png', quality: 100 }). For lower bandwidth, use JPEG with a lower quality.
Why does closeLiveURL return Unknown liveURLId for this session.?
The link was already invalidated by Done, submitted feedback, expiry or an earlier close. Don't call closeLiveURL after you've seen one of those outcomes; terminate the browser instead.
Why isn't my subscription receiving liveComplete?
The link was minted on a different connection. Start the subscription and send the mint on the same persistent WebSocket session, before you share the link. Past events aren't replayed to a later connection.
The link still works after the BrowserQL IDE says the operation ended. Is the browser still running?
Yes, if you reserved time with reconnect. An explicitly preserved browser keeps serving the link. The reverse doesn't hold: an unexpired link can't revive a browser that's already terminated. See reconnect and session cleanup.