---
title: Webhooks
description: 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.

```bash title="cURL"
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"
  }'
```

```python title="Python"
run = requests.post(f"{API}/runs", headers=H, json={
    "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",
}).json()
save_secret(run["id"], run["webhook_secret"])  # your storage
```

```js title="Node.js"
const run = await fetch(`${API}/runs`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    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',
  }),
}).then((r) => r.json());
await saveSecret(run.id, run.webhook_secret); // your storage
```

The response, abridged:

```json
{
  "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](/docs/browser-agent#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](/docs/api/browser#the-run-object) at that moment:

```http
POST /hooks/browser HTTP/1.1
Host: api.example.com
content-type: application/json
x-lovely-event: run.waiting
x-lovely-signature: sha256=8d6f0c3b2a1e9f8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d
```

```json
{
  "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:

1. Read the raw body before parsing it. Re-serialized JSON won't match.
2. Find the run id in the body (`run.id`) and look up the `webhook_secret` you stored for it. Reject ids you don't know.
3. Compute the HMAC and compare it to the header with a constant-time comparison.

```python title="Python"
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 "", 204
```

```js title="Node.js"
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/hooks/browser', express.raw({ type: 'application/json' }), async (req, res) => {
  const body = req.body; // Buffer with the raw bytes
  const event = JSON.parse(body.toString('utf8'));
  const secret = await loadSecret(event.run?.id); // what you stored at run creation
  if (!secret) return res.sendStatus(404);
  const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex'));
  const got = Buffer.from(req.get('x-lovely-signature') ?? '');
  if (got.length !== expected.length || !crypto.timingSafeEqual(got, expected)) return res.sendStatus(401);

  const { run } = event;
  if (event.event === 'run.waiting') await notifyPerson(run.id, run.needs);
  else if (event.event === 'run.finished') await saveResult(run.id, run.status, run.answer, run.data);
  res.sendStatus(204);
});

app.listen(3000);
```

## Delivery

- Each event is sent once. There are no retries, and a non-`2xx` response is not resent.
- Lovely Router waits up to 5 seconds for your response. Reply with `2xx` right 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`:

```bash title="cURL"
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](/docs/browser-agent/human-in-the-loop) covers every kind of reply.
