Hand-off and webhooks
Some steps need a person: a CAPTCHA, a code sent to a phone, a consent only the account owner can give. Hand-off gives a running browser to a person through a link and takes it back when they are done, for sessions you drive and for agent runs. Webhooks tell your server when something happens, signed so you can check they came from us.
Hand-off to a person
POST /api/v1/browsers/<id>/handoff on a running session returns a live link. Whoever opens it sees the browser and can click, type and scroll in it, without an account. When they are finished, they press I'm done on that page, and your code carries on in the same browser, with whatever they did (a solved check, a signed-in account) in place.
curl -X POST -H "authorization: Bearer cc_live_..." -H "content-type: application/json" \
-d '{"reason": "captcha on the login page", "timeoutSec": 900}' \
https://www.clearcotelabs.com/api/v1/browsers/<id>/handoff
# {
# "state": "waiting",
# "reason": "captcha on the login page",
# "since": "2026-10-02T09:30:00.000Z",
# "expiresAt": "2026-10-02T09:45:00.000Z",
# "liveUrl": "https://www.clearcotelabs.com/live/bs_…?handoff=1#t=…"
# }
# end it from your side instead of the button
curl -X POST -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/browsers/<id>/handoff/done
# { "state": "done", "reason": "…", "since": "…", "expiresAt": "…", "doneAt": "…", "liveUrl": null }reason(optional, up to 500 characters on one line) is a note for you and your webhook handler: it is in the answer, the session view, the dashboard and thehandoff.requestedwebhook. The page the person opens shows a standard “take over, then press I'm done” message, so say what to do when you send the link.timeoutSecis a whole number from 60 to 3600, default 600: the link works untilexpiresAt.- The token is after the
#inliveUrl, so it never reaches a server log. Pass the link on whole. - The session has to be running: otherwise the answer is
409 NOT_RUNNING. A browser on an older server answers409 HANDOFF_UNSUPPORTED; start a new session. Asking again while a hand-off waits returns that same hand-off, with its link, reason andexpiresAtunchanged. - Marking it done twice gives the same answer. It is
409 NO_HANDOFFif none was asked for, and409 HANDOFF_EXPIREDif it timed out first. - While a hand-off waits, the session is not closed as idle, even if neither your script nor the person does anything. The session's own
timeoutSec(from when you created it),maxGband your balance still apply. Once the hand-off is done or has timed out, the idle timeout counts again from that moment. - The link is a control link: anyone who has it can drive the browser until it expires, signed in as whoever the browser is signed in as. Send it only to the person who should act.
- A
handoff.requestedwebhook carries the sameliveUrl, so a webhook handler can pass the link on (to a chat, an email, a ticket) without your script doing it.
GET /api/v1/browsers/<id> shows the hand-off. handoff is null until you ask for one, and liveUrl is null unless state is waiting. Once expiresAt has passed without “I'm done”, or the session has ended first, state reads timeout and the link stops working. A session you drive keeps running; an agent run ends (see below).
"handoff": {
"state": "waiting", // waiting | done | timeout
"reason": "captcha on the login page",
"since": "2026-10-02T09:30:00.000Z",
"expiresAt": "2026-10-02T09:45:00.000Z",
"doneAt": null,
"liveUrl": "https://www.clearcotelabs.com/live/bs_…?handoff=1#t=…"
}From a script
Your script stays connected during a hand-off. Wait until the person is done, then continue:
from playwright.sync_api import sync_playwright
from clearcote.cloud import Cloud
cloud = Cloud()
session = cloud.browsers.create(country="de", profile={"name": "shop-account", "persist": True})
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(session["connectUrl"])
page = browser.contexts[0].new_page()
page.goto("https://shop.example.com/login")
if page.get_by_text("Verify you are human").count():
h = cloud.browsers.handoff(session["id"], reason="captcha on the login page", timeout_sec=900)
print("Send this link:", h["liveUrl"])
view = cloud.browsers.wait_handoff(session["id"]) # returns once it is done, or has timed out
if view["handoff"]["state"] != "done":
raise SystemExit("nobody finished the check in time")
page.goto("https://shop.example.com/orders")
browser.close()wait_handoff / waitHandoff polls GET /api/v1/browsers/<id> every 2 seconds until handoff.state is no longer waiting, and returns that session view. Give it a timeout (seconds) to stop waiting earlier: it then raises CloudTimeoutError and the hand-off stays open. To end a hand-off from your side instead of the button, call cloud.browsers.handoff_done(id) (Node: handoffDone(id)). Without the SDK, poll GET /api/v1/browsers/<id> every few seconds until handoff.state is done (or timeout), or wait for the handoff.done webhook. No webhook is sent when a hand-off times out, so also stop waiting at expiresAt.
In agent runs
Create the run with handoff: true. When Jet stops because it needs something it does not have (needs_input) or cannot get further (blocked), the run does not end: it pauses as waiting_for_human, the run's handoff holds the liveUrl, and a handoff.requested webhook goes out.
curl -X POST https://www.clearcotelabs.com/api/v1/runs \
-H "authorization: Bearer cc_live_..." -H "content-type: application/json" \
-d '{"task": "Open Orders and read the latest order number", "url": "https://shop.example.com/orders",
"profile": {"name": "shop-account", "persist": true}, "handoff": true, "handoffTimeoutSec": 1800,
"timeoutSec": 3600}'- When the person presses “I'm done”, Jet starts again on the same page with the same task, and sees the page as they left it (if they closed that tab, it starts again at the run's
url). - A run can be handed off up to three times.
result.handoffscounts them, and the steps from before and after are all inresult.steps. handoffTimeoutSec(60 to 3600, default 600) is how long each hand-off waits. If nobody finishes in time, the run ends with the status Jet stopped on, so it isfailed. The run's owntimeoutSec(default 900) still counts during a hand-off: raise it when you raisehandoffTimeoutSec.- Use it with a
profileandpersist: true: what the person signs in to is saved, and the next run does not need them.
Webhooks
A webhook is a URL of yours that we send a signed POST to when something happens. Create one with the API, the SDK, the CLI or the dashboard settings:
curl -X POST https://www.clearcotelabs.com/api/v1/webhooks \
-H "authorization: Bearer cc_live_..." -H "content-type: application/json" \
-d '{"url": "https://example.com/hooks/clearcote", "events": ["run.finished", "handoff.requested"], "description": "prod"}'
# {
# "id": "wh_…",
# "url": "https://example.com/hooks/clearcote",
# "events": ["run.finished", "handoff.requested"],
# "description": "prod",
# "createdAt": "…",
# "secret": "whsec_…" <- shown once: store it now
# }The secret is only in the create answer; it is what you check signatures with. Lost it? Delete the webhook and create a new one.
# your webhooks, with the last delivery attempt of each
curl -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/webhooks
# { "webhooks": [ { "id": "wh_…", "url": "…", "events": [ … ], "description": "prod", "createdAt": "…",
# "lastDelivery": { "at": "…", "status": 200, "ok": true } } ] }
# send a ping to it now (once, never retried)
curl -X POST -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/webhooks/<id>/test
# { "id": "evt_…", "ok": true, "status": 200 }
# delete it (retries still waiting for it are dropped)
curl -X DELETE -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/webhooks/<id>
# { "ok": true }lastDelivery is null until something was sent. Its status, and the status of a test, is the HTTP status your server answered, or null when there was no answer; a failed test also has an error saying why. A test ping is sent even when events does not list ping.
Rules
- At most 5 webhooks per account; a sixth is a
409 WEBHOOK_LIMIT.descriptionis up to 200 characters on one line. eventspicks what is sent (table below). Leave it out, or send an empty list, for everything exceptping.- The URL must be
https://, at most 2048 characters, without a user name or password, and on the public internet.localhost, single-label names (likehttps://intranet) and IP addresses in private, loopback, link-local, carrier-grade NAT, multicast, documentation, reserved or cloud-metadata ranges are refused when you create the webhook, and so is any IPv6 address that wraps an IPv4 one (::ffff:…). At every delivery the host name is looked up: if any of its addresses is in those ranges, nothing is sent and the attempt counts as failed. - Redirects are not followed, and each attempt waits at most 5 seconds for an answer. Answer quickly with a
2xxand do the work afterwards.
Events
| type | data | Sent when |
|---|---|---|
session.started | { id, kind: "cdp" | "run", worker } | A browser started. kind is "cdp" for a session you drive, "run" for an agent run. |
session.ended | { id, kind, reason, usage: { bytesUp, bytesDown, seconds }, costEur } | A session ended, why, and what it used. costEur is one number: browser and agent together for a run. |
run.finished | { id, status, result: { status, detail, url, title, output, outputError }, costEur } | An agent run finished. status is succeeded, failed or cancelled; result is the main part of its result, or null if it left none; costEur is { browser, agent, total }. |
handoff.requested | { id, kind, reason, liveUrl, expiresAt } | A browser was handed to a person. Send liveUrl to whoever should act. |
handoff.done | { id, kind } | The person pressed "I'm done", or you marked it done (API or dashboard). Not sent when a hand-off times out. |
recording.ready | { id, bytes, durationSec } | A recording can be downloaded. |
ping | {} | Sent by the test call, to check your endpoint. |
id in data is the session or run id (they are the same thing). Fetch the full state with GET /api/v1/runs/<id> or GET /api/v1/browsers/<id> when you need more than the event carries. A queued run that is cancelled or expires before a server picks it up sends neither session.ended nor run.finished.
What a delivery looks like
POST /hooks/clearcote HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Clearcote-Webhooks/1
Clearcote-Webhook-Id: evt_…
Clearcote-Signature: t=1790932461,v1=6a1f0c…
{
"id": "evt_…",
"type": "run.finished",
"createdAt": "2026-10-02T09:14:21.312Z",
"data": {
"id": "bs_…",
"status": "succeeded",
"result": {
"status": "done",
"detail": null,
"url": "https://www.gov.uk/bank-holidays",
"title": "UK bank holidays - GOV.UK",
"output": { "name": "Christmas Day", "date": "2026-12-25" },
"outputError": null
},
"costEur": { "browser": …, "agent": …, "total": … }
}
}Retries
A delivery counts when your server answers with a 2xx. Anything else (another status, a redirect, a timeout, a refused connection) is tried again. There are 6 attempts in all: right away, then about 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours after the event. After the sixth the delivery is marked failed and lastDelivery shows it. A test ping is sent once and never retried.
- Expect duplicates. A delivery can arrive more than once, for example when your answer was lost.
Clearcote-Webhook-Id(the envelope'sid) is the same on every attempt: skip ids you have already handled. - Do not rely on order. A retried event can arrive after a later one. Use
createdAt, or fetch the current state.
Verifying signatures
Every delivery is signed with your webhook's secret:
Clearcote-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the secret as key>To check one, compute the same HMAC over the raw request body (the exact bytes, before any JSON parsing), compare it to v1 in constant time, and reject a t more than 5 minutes from now, so an old delivery cannot be replayed. The SDK does all of that in one call:
import os
from flask import Flask, abort, request
from clearcote.cloud import verify_webhook
app = Flask(__name__)
SECRET = os.environ["CLEARCOTE_WEBHOOK_SECRET"] # whsec_...
@app.post("/hooks/clearcote")
def clearcote_hook():
try:
event = verify_webhook(request.get_data(), request.headers.get("Clearcote-Signature", ""), SECRET)
except ValueError:
abort(400) # bad or old signature
if event["type"] == "run.finished":
data = event["data"]
print(data["id"], data["status"], data["result"]["output"])
return "", 204verify_webhook(raw_body, signature_header, secret, tolerance_sec=300) (also importable from clearcote) returns the parsed event and raises ValueError when the header is missing or malformed, the signature does not match, or t is out of range. In Node, verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) does the same, synchronously, and throws an Error. raw_body may be bytes or a string, the whole whsec_… string is the key, and any one of several v1 values matching is enough. A missing or empty secret also raises, so an unset environment variable never lets a delivery through. Pass tolerance_sec=None (Node: null) to skip the time check, for example when replaying a saved delivery.
In any language
No SDK needed: it is a plain HMAC-SHA256 with the whole secret string (whsec_ included) as the key. In Python with only the standard library:
import hashlib, hmac, json, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> dict:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, sig = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
raise ValueError("missing or old timestamp")
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise ValueError("bad signature")
return json.loads(raw_body)And to check a saved delivery by hand:
# T = the t= value of the header, body.json = the raw body exactly as received
{ printf '%s.' "$T"; cat body.json; } | openssl dgst -sha256 -hmac "$CLEARCOTE_WEBHOOK_SECRET"
# the hex at the end must equal the v1= value