Webhooks
Get an HTTPS POST when a browser agent run starts waiting for a person or finishes, signed so you can verify it came from Lovely Router.
Instead of polling, give a run a webhook URL. Lovely Router posts to it when the run starts waiting for a person and when it finishes. Each request is signed with a secret that is unique to the run.
Add a webhook to a run#
Pass an https URL as webhook when you start the run. The response includes webhook_secret. It is returned only here, so store it with the run id.
curl https://lovelyrouter.com/v1/browser/runs \
-H "Authorization: Bearer $LOVELY_API_KEY" \
-H "content-type: application/json" \
-d '{
"goal": "Get a car insurance quote for the details below and tell me the quote reference and monthly premium. ...",
"start_url": "https://insure.example.com/quote",
"webhook": "https://api.example.com/hooks/browser"
}'The response, abridged:
{
"id": "brun_9c2e4a6b8d0f1a3c5e7b9d1f",
"object": "browser_run",
"status": "running",
"webhook_secret": "4f1d0c9b8a7e6d5c4b3a29180f7e6d5c4b3a2918a7b6c5d4",
"poll": "/v1/browser/runs/brun_9c2e4a6b8d0f1a3c5e7b9d1f?wait=30",
"charge_usd": null,
"reserved_usd": 0.445
}A webhook that isn't an https URL returns 400 with "webhook" must be an https URL. The Python and Node.js samples use the API, H and headers setup from Run your first task.
Events#
| Event | Sent when |
|---|---|
run.waiting | The run starts waiting for a person: a question, an approval or a handover. Sent once per pause. |
run.finished | The run ends with any final status: succeeded, failed, timed_out or stopped. |
Payload#
Each event is a POST with a JSON body holding the event name and the run object at that moment:
POST /hooks/browser HTTP/1.1
Host: api.example.com
content-type: application/json
x-lovely-event: run.waiting
x-lovely-signature: sha256=8d6f0c3b2a1e9f8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d{
"event": "run.waiting",
"run": {
"id": "brun_9c2e4a6b8d0f1a3c5e7b9d1f",
"object": "browser_run",
"status": "waiting_for_human",
"goal": "Get a car insurance quote for the details below and tell me the quote reference and monthly premium. ...",
"start_url": "https://insure.example.com/quote",
"engine": "chromium",
"mode": "fast",
"model": null,
"steps": 14,
"max_steps": 50,
"billable_time_s": 0,
"time_usd": 0,
"current_url": "https://insure.example.com/quote/review",
"answer": null,
"data": null,
"error": null,
"needs": {
"kind": "approval",
"prompt": "Approve: click button \"Get my quote\" on Review your details?",
"fields": ["Full name: \"John Smith\"", "Coverage: \"Standard\""],
"action": "Get my quote",
"screenshot": "/v1/browser/runs/brun_9c2e4a6b8d0f1a3c5e7b9d1f/screenshot",
"since": "2026-10-11T09:41:03.000Z",
"reply": "/v1/browser/runs/brun_9c2e4a6b8d0f1a3c5e7b9d1f/reply"
},
"profile_saved": null,
"usage": { "decision_calls": 16, "llm_calls": 3 },
"created_at": "2026-10-11T09:40:21.000Z",
"finished_at": null
}
}The run in a webhook has no charge_usd or reserved_usd: the charge is settled a few seconds after the run ends. To get it, fetch the run with GET /v1/browser/runs/{id}.
Verify the signature#
x-lovely-signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the run's webhook_secret (as a UTF-8 string). To verify a request:
- Read the raw body before parsing it. Re-serialized JSON won't match.
- Find the run id in the body (
run.id) and look up thewebhook_secretyou stored for it. Reject ids you don't know. - Compute the HMAC and compare it to the header with a constant-time comparison.
import hashlib
import hmac
import json
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/hooks/browser")
def browser_hook():
body = request.get_data() # raw bytes
event = json.loads(body)
secret = load_secret(event["run"]["id"]) # what you stored at run creation
if secret is None:
abort(404)
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("x-lovely-signature", "")):
abort(401)
run = event["run"]
if event["event"] == "run.waiting":
notify_person(run["id"], run["needs"]) # e.g. send needs.prompt to the right person
elif event["event"] == "run.finished":
save_result(run["id"], run["status"], run["answer"], run["data"])
return "", 204Delivery#
- Each event is sent once. There are no retries, and a non-
2xxresponse is not resent. - Lovely Router waits up to 5 seconds for your response. Reply with
2xxright away and do slow work, like notifying a person, in the background. - Events for one run are sent in order, but treat the webhook as a signal, not a source of truth. If an event is missed, the run is still there: fetch it with
GET /v1/browser/runs/{id}.
For jobs that must not be lost, keep a fallback: a periodic check of runs that are still running or waiting_for_human after a while. GET /v1/browser/runs lists your 50 most recent runs.
Reply from a webhook#
A run.waiting event carries everything you need to ask a person: needs.prompt, needs.fields for approvals, needs.live_view_url for handovers, and the needs.screenshot path. When they answer, post to needs.reply:
curl https://lovelyrouter.com/v1/browser/runs/brun_9c2e4a6b8d0f1a3c5e7b9d1f/reply \
-H "Authorization: Bearer $LOVELY_API_KEY" \
-H "content-type: application/json" \
-d '{"approve": true}'The run continues and, when it ends, sends run.finished. Human in the loop covers every kind of reply.