Skip to content

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.

bash
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 the handoff.requested webhook. 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. timeoutSec is a whole number from 60 to 3600, default 600: the link works until expiresAt.
  • The token is after the # in liveUrl, 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 answers 409 HANDOFF_UNSUPPORTED; start a new session. Asking again while a hand-off waits returns that same hand-off, with its link, reason and expiresAt unchanged.
  • Marking it done twice gives the same answer. It is 409 NO_HANDOFF if none was asked for, and 409 HANDOFF_EXPIRED if 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), maxGb and 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.requested webhook carries the same liveUrl, 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).

json
"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.

bash
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.handoffs counts them, and the steps from before and after are all in result.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 is failed. The run's own timeoutSec (default 900) still counts during a hand-off: raise it when you raise handoffTimeoutSec.
  • Use it with a profile and persist: 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.

bash
# 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. description is up to 200 characters on one line.
  • events picks what is sent (table below). Leave it out, or send an empty list, for everything except ping.
  • The URL must be https://, at most 2048 characters, without a user name or password, and on the public internet. localhost, single-label names (like https://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 2xx and do the work afterwards.

Events

typedataSent 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

http
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's id) 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:

text
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 "", 204

verify_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:

python
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:

bash
# 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