Docs
Dashboard
Guides / AccountBeta

Webhooks

Signed HTTPS callbacks for every kind of event (sessions, agent runs, CAPTCHAs, tasks, usage limits, keys and credentials), with sample payloads, typed receivers, retries, the delivery log and local development.

Webhooks tell your server when something starts, finishes, needs a person or changes: a session starts, is about to run out of time or ends, an agent run waits for you or finishes, a task runs, a limit is reached, a key or a credential changes, or a run waits for a 2FA code. Boxline sends a signed HTTPS POST to each endpoint that asked for that event, so you do not have to poll. They are on every plan.

Events

There are 24 event types. Subscribe an endpoint to the ones it needs, or to "*" for every type, those added later too (see Every type). Each type below has a sample body: what POST /v1/webhooks/:id/test sends for it.

Sessions

TypeWhen, and what it holds
session.startedA session is ready (its machine is set up): the session as GET shows it, without its signed URLs. Also a session an agent run or a task starts for itself; not a resume. Its signed URLs are null.
session.expiringA running session stops by time in about a minute (once per expiry time; again after a change of its `timeout`): give it more time now (PATCH /v1/sessions/:id) if it should go on. Once per expiry time: a new timeout or a resume moves the time and arms it again. To keep the session going, send a longer timeout with PATCH /v1/sessions/:id.
session.stoppedA session stopped (you stopped it, it timed out, went idle, lost its machine…): the session, with its stopReason and when it will be deleted. The session once stopped, whichever way it stopped (you, its time, idle, no clients, an agent run or task finishing, a limit, a lost machine): status: "STOPPED", with stoppedAt, stopReason, deletesAt and usage. Its URLs and attention are null. Setup commands are left out (they often hold tokens). All three session events carry a fixed list of fields: a field added to sessions later appears here only after a review.
session.deletedA session was deleted (by you, or when its days ran out): the session, with its deleteReason. The session once deleted: by DELETE /v1/sessions/:id (also in bulk), or by retention when its deletesAt passed. status: "DELETED", with deletedAt, deleteReason (requested or retention) and usage. Not sent when a session stops. A session you stop and later delete gets session.stopped when it stops, and session.deleted when it is deleted.
Sample session.started
session.started (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "session.started",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "00000000-0000-4000-8000-000000000000",    "status": "RUNNING",    "projectId": "proj_sample0000000000",    "region": "sample",    "browser": true,    "shell": false,    "keepAlive": false,    "timeout": 300,    "idleTimeout": null,    "createdAt": "2026-09-30T10:00:00.000Z",    "startedAt": "2026-09-30T10:00:00.000Z",    "expiresAt": "2026-09-30T10:01:00.000Z",    "stoppedAt": null,    "stopReason": null,    "deletesAt": null,    "deletedAt": null,    "deleteReason": null,    "proxy": null,    "captcha": "ask",    "blockAds": false,    "cookieBanners": "reject",    "extensions": [],    "profileId": null,    "setupStatus": "none",    "usage": {      "seconds": 0,      "costUsd": 0    },    "connectUrl": null,    "liveUrl": null,    "terminalUrl": null,    "attention": null,    "userMetadata": {      "sample": true    }  }}
Sample session.expiring
session.expiring (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "session.expiring",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "sessionId": "00000000-0000-4000-8000-000000000000",    "expiresAt": "2026-09-30T10:01:00.000Z",    "secondsLeft": 60,    "userMetadata": {      "sample": true    }  }}
Sample session.stopped
session.stopped (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "session.stopped",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "00000000-0000-4000-8000-000000000000",    "status": "STOPPED",    "projectId": "proj_sample0000000000",    "region": "sample",    "browser": true,    "shell": false,    "keepAlive": false,    "timeout": 300,    "idleTimeout": null,    "createdAt": "2026-09-30T10:00:00.000Z",    "startedAt": "2026-09-30T10:00:00.000Z",    "expiresAt": "2026-09-30T10:01:00.000Z",    "stoppedAt": "2026-09-30T10:00:00.000Z",    "stopReason": "requested",    "deletesAt": "2026-10-07T10:00:00.000Z",    "deletedAt": null,    "deleteReason": null,    "proxy": null,    "captcha": "ask",    "blockAds": false,    "cookieBanners": "reject",    "extensions": [],    "profileId": null,    "setupStatus": "none",    "usage": {      "seconds": 0,      "costUsd": 0    },    "connectUrl": null,    "liveUrl": null,    "terminalUrl": null,    "attention": null,    "userMetadata": {      "sample": true    }  }}
Sample session.deleted
session.deleted (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "session.deleted",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "00000000-0000-4000-8000-000000000000",    "status": "DELETED",    "projectId": "proj_sample0000000000",    "region": "sample",    "browser": true,    "shell": false,    "keepAlive": false,    "timeout": 300,    "idleTimeout": null,    "createdAt": "2026-09-30T10:00:00.000Z",    "startedAt": "2026-09-30T10:00:00.000Z",    "expiresAt": "2026-09-30T10:01:00.000Z",    "stoppedAt": "2026-09-30T10:00:00.000Z",    "stopReason": "requested",    "deletesAt": null,    "deletedAt": "2026-09-30T10:00:00.000Z",    "deleteReason": "requested",    "proxy": null,    "captcha": "ask",    "blockAds": false,    "cookieBanners": "reject",    "extensions": [],    "profileId": null,    "setupStatus": "none",    "usage": {      "seconds": 0,      "costUsd": 0    },    "connectUrl": null,    "liveUrl": null,    "terminalUrl": null,    "attention": null,    "userMetadata": {      "sample": true    }  }}

Agent runs

TypeWhen, and what it holds
agent_run.startedAn agent run started (also a task's run): its task, session, model and mode.
agent_run.waitingAn agent run waits for a person: the agent asked for help, a person paused it, or a CAPTCHA needs solving. consoleUrl is the run in the console, which needs a login; the signed live view is never included: get it with GET /v1/sessions/:id.
agent_run.resumedAn agent run goes on: resumed (after a pause, or after it stopped early), answered with a message, or its CAPTCHA was solved.
agent_run.finishedAn agent run completed, failed or was canceled: status, result, error and usage. With errorCode, resumable (until when the run can be resumed, or null), messages (how many of your messages the model read) and variableNames: the run's variables by name, never their values. result is at most 32,000 characters, or the JSON answer (null when larger, with truncated: true).
Sample agent_run.started
agent_run.started (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "agent_run.started",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "run_sample000000000000",    "status": "running",    "task": "Open example.com and read its title (a sample run)",    "sessionId": "00000000-0000-4000-8000-000000000000",    "provider": "anthropic",    "model": "sample-model",    "mode": "tools",    "taskId": null,    "taskRunId": null,    "createdAt": "2026-09-30T10:00:00.000Z"  }}
Sample agent_run.waiting
agent_run.waiting (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "agent_run.waiting",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "run_sample000000000000",    "sessionId": "00000000-0000-4000-8000-000000000000",    "state": "waiting",    "by": "agent",    "reason": "Please sign in to your account, then resume the run.",    "consoleUrl": "https://app.boxline.dev/agent-runs?run=run_sample000000000000",    "since": "2026-09-30T10:00:00.000Z"  }}
Sample agent_run.resumed
agent_run.resumed (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "agent_run.resumed",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "run_sample000000000000",    "sessionId": "00000000-0000-4000-8000-000000000000",    "state": "resumed",    "by": "agent",    "via": "message",    "at": "2026-09-30T10:00:00.000Z"  }}
Sample agent_run.finished
agent_run.finished (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "agent_run.finished",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "run_sample000000000000",    "status": "completed",    "task": "Open example.com and read its title (a sample run)",    "sessionId": "00000000-0000-4000-8000-000000000000",    "provider": "anthropic",    "model": "sample-model",    "result": "Example Domain",    "resultText": null,    "structured": false,    "error": null,    "errorCode": null,    "resumable": null,    "variableNames": [],    "messages": 0,    "usage": {      "inputTokens": 0,      "outputTokens": 0,      "costUsd": 0    },    "taskId": null,    "taskRunId": null,    "createdAt": "2026-09-30T10:00:00.000Z",    "finishedAt": "2026-09-30T10:00:00.000Z"  }}

CAPTCHAs

TypeWhen, and what it holds
captcha.waitingA CAPTCHA waits for a person in a session (a person's turn): session, kind, host and why. The page by host only.
captcha.solvedA CAPTCHA was solved: automatically (captcha "solve") or while a person had it.
captcha.failedAutomatic solving of a CAPTCHA failed, and a person is asked instead: why.
Sample captcha.waiting
captcha.waiting (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "captcha.waiting",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "sessionId": "00000000-0000-4000-8000-000000000000",    "kind": "recaptcha",    "host": "shop.example.com",    "reason": null,    "since": "2026-09-30T10:00:00.000Z",    "captcha": "ask"  }}
Sample captcha.solved
captcha.solved (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "captcha.solved",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "sessionId": "00000000-0000-4000-8000-000000000000",    "runId": "run_sample000000000000",    "kind": "recaptcha",    "host": "shop.example.com",    "by": "auto",    "ms": 12000  }}
Sample captcha.failed
captcha.failed (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "captcha.failed",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "sessionId": "00000000-0000-4000-8000-000000000000",    "runId": null,    "kind": "hcaptcha",    "host": "shop.example.com",    "by": "auto",    "ms": 30000,    "reason": "the CAPTCHA was not solved in time"  }}

Crawls

TypeWhen, and what it holds
crawl.finishedA crawl completed, failed or was canceled: status and page counts.
Sample crawl.finished
crawl.finished (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "crawl.finished",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "crawl_sample0000000000",    "status": "completed",    "url": "https://example.com/docs",    "pagesDone": 12,    "pagesFailed": 0,    "skippedByRobots": 1,    "error": null,    "createdAt": "2026-09-30T10:00:00.000Z",    "finishedAt": "2026-09-30T10:00:00.000Z"  }}

Tasks

TypeWhen, and what it holds
task_run.startedA task's run started (by hand or on its schedule): the task, the agent run and the variables' names. variables lists names only, never values.
task_run.finishedA task's run finished (by hand or on its schedule): status, result (JSON with an output schema), error and usage. variables lists names only, never values, not even plain ones.
task.schedule_pausedThe platform switched a task's schedule off (it could not be read, or has no more times): why.
Sample task_run.started
task_run.started (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "task_run.started",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "taskId": "task_sample00000000000",    "taskName": "Sample task",    "taskRunId": "trun_sample0000000000",    "runId": "run_sample000000000000",    "sessionId": "00000000-0000-4000-8000-000000000000",    "status": "running",    "scheduled": true,    "scheduledFor": "2026-09-30T10:00:00.000Z",    "variables": [      "city"    ],    "startedAt": "2026-09-30T10:00:00.000Z"  }}
Sample task_run.finished
task_run.finished (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "task_run.finished",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "taskId": "task_sample00000000000",    "taskName": "Sample task",    "taskRunId": "trun_sample0000000000",    "runId": "run_sample000000000000",    "status": "completed",    "scheduled": true,    "scheduledFor": "2026-09-30T10:00:00.000Z",    "result": "Example Domain",    "resultText": "Example Domain",    "structured": false,    "error": null,    "errorCode": null,    "usage": {      "inputTokens": 0,      "outputTokens": 0,      "costUsd": 0    },    "variables": [      "city"    ],    "createdAt": "2026-09-30T10:00:00.000Z",    "finishedAt": "2026-09-30T10:00:00.000Z"  }}
Sample task.schedule_paused
task.schedule_paused (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "task.schedule_paused",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "taskId": "task_sample00000000000",    "taskName": "Sample task",    "reason": "schedule_invalid",    "message": "the schedule could not be read (a sample), so it was switched off",    "pausedAt": "2026-09-30T10:00:00.000Z"  }}

Usage

TypeWhen, and what it holds
usage.limit_reachedThe project reached a limit of its plan (model spend, proxy data, CAPTCHA solves, searches, concurrent sessions), a spending limit of its organization or its own, or the organization's credit ran out; once per limit and period. kind is model_spend (requests then fail with spend_limit), proxy_gb, captcha_solves, searches or concurrency for a plan limit. For money it is spend_limit (the organization's spending limit, sent to each of its projects), project_spend_limit (the project's own) or credit (the organization's credit is used up, sent to each of its projects). Once per limit and period, however many requests are refused: period is the UTC month, or the UTC hour for concurrency; resetsAt the 1st of next month (null for concurrency). For credit, limit is the credit used this month and used the whole spend.
Sample usage.limit_reached
usage.limit_reached (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "usage.limit_reached",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "kind": "model_spend",    "limit": 100,    "used": 100.02,    "period": "2026-09",    "resetsAt": "2026-10-01T00:00:00.000Z"  }}

Security

TypeWhen, and what it holds
api_key.createdAn API key was created: its id, prefix and name, and who made it (never the key). The key's id, name and prefix (its first 12 characters, as the list shows), never the key. Not for the first key made at sign-up.
api_key.revokedAn API key was revoked: its id, prefix and name, and who revoked it. Never the key. Not for keys an account recovery revokes (it switches the webhooks off too).
credential.code_neededA run, action or boxline-otp waits for a password's 2FA code or sign-in link (code source push or url): forward the site's email or SMS now (never a code or link). credential is the password's name, type is "code" or "link", and runId is null for an action or a boxline-otp command. Once per wait, so send the code or link now with POST /v1/credentials/:name/codes. Never a code, a link or a value.
credential.changedA credential (a password or a secret) was created, changed, linked to a profile or deleted: its name, type and who (never a value). A credential (type: "password" or "secret", by its name): created, updated, linked to or unlinked from a browser profile (changed: ["profiles"]) or deleted. The name and which fields changed, never a value.
Sample api_key.created
api_key.created (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "api_key.created",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "key_sample000000000000",    "prefix": "bxl_sample00",    "name": "Sample key",    "by": "key:key_sample000000000000"  }}
Sample api_key.revoked
api_key.revoked (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "api_key.revoked",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "key_sample000000000000",    "prefix": "bxl_sample00",    "name": "Sample key",    "by": "key:key_sample000000000000"  }}
Sample credential.code_needed
credential.code_needed (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "credential.code_needed",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "credential": "SAMPLE_SHOP",    "type": "code",    "sessionId": "00000000-0000-4000-8000-000000000000",    "runId": "run_sample000000000000"  }}
Sample credential.changed
credential.changed (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "credential.changed",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "name": "SAMPLE_TOKEN",    "action": "updated",    "type": "secret",    "by": "key:key_sample000000000000",    "changed": [      "value"    ]  }}

Webhooks

TypeWhen, and what it holds
webhook.changedA webhook endpoint was created, changed, deleted or given a new secret: its id, host and events (never its secret). The endpoint by id and host only, never its URL (which often holds a token) or its secret. A deleted endpoint gets nothing more; the others hear of it.
webhook.disabledOne of the project's webhook endpoints was switched off (it answered 410, or kept failing).
Sample webhook.changed
webhook.changed (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "webhook.changed",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "endpointId": "wh_sample000000000000",    "host": "hooks.example.com",    "action": "updated",    "events": [      "*"    ],    "enabled": true,    "by": "key:key_sample000000000000",    "changed": [      "events"    ]  }}
Sample webhook.disabled
webhook.disabled (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "webhook.disabled",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "endpointId": "wh_sample000000000000",    "host": "hooks.example.com",    "reason": "failing",    "failingSince": "2026-09-30T10:00:00.000Z",    "disabledAt": "2026-09-30T10:00:00.000Z"  }}

Extensions

TypeWhen, and what it holds
extension.uploadedA Chrome extension was uploaded: its id, name and version, and who.
extension.deletedA Chrome extension was deleted: its id and name, and who.
Sample extension.uploaded
extension.uploaded (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "extension.uploaded",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "ext_sample000000000000",    "name": "Sample extension",    "version": "1.0.0",    "sha256": "0000000000000000000000000000000000000000000000000000000000000000",    "by": "key:key_sample000000000000"  }}
Sample extension.deleted
extension.deleted (a sample, as "send test" makes it)
{  "id": "evt_sample000000000000",  "type": "extension.deleted",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "proj_sample0000000000",  "test": true,  "data": {    "id": "ext_sample000000000000",    "name": "Sample extension",    "version": "1.0.0",    "sha256": "0000000000000000000000000000000000000000000000000000000000000000",    "by": "key:key_sample000000000000"  }}

by on a change says who made it: "user:<email>" (a console login), "key:<api key id>" (an API key), "support" (Boxline support acting for you) or "platform". For the task events, see Tasks. For credential.code_needed, see 2FA codes and sign-in links. For the spending limit and credit kinds of usage.limit_reached, and when each is sent, see Credit and spending limits.

Every type, and the list of types

events: ["*"] subscribes an endpoint to every type, also types added later. A list holding "*" is stored as ["*"]; a list holds at most 100 entries.

curl -X POST https://api.boxline.dev/v1/webhooks \  -H "x-api-key: $BOXLINE_API_KEY" \  -H "content-type: application/json" \  -d '{"url": "https://example.com/webhooks/boxline", "events": ["*"]}'

GET /v1/webhooks/event-types lists every type an endpoint can subscribe to, with its group and description, in the order a picker shows them, and all: "*". See List event types.

curl https://api.boxline.dev/v1/webhooks/event-types \  -H "x-api-key: $BOXLINE_API_KEY"

What a delivery looks like

Every delivery is a POST to your URL with a JSON body and these headers: Content-Type: application/json, User-Agent, Boxline-Signature, Boxline-Event-Id, Boxline-Event-Type, Boxline-Delivery-Id and Boxline-Attempt (1, 2, …).

Body
{  "id": "evt_…",  "type": "session.stopped",  "createdAt": "2026-09-30T10:00:00.000Z",  "projectId": "…",  "data": { "id": "…", "status": "STOPPED", "stopReason": "requested", "deletesAt": "…", "…": "…" }}

Events never hold a secret or a variable’s value: no signed session URLs (call GET /v1/sessions/:id for the live view), no proxy passwords, no API keys, no endpoint URLs or signing secrets, and an agent run’s text has every variable as %name%. Variables, credentials and keys appear by name only. A “send test” delivery also has "test": true.

Add an endpoint

Give a public HTTPS URL and the events it wants. The answer has the endpoint’s signing secret, whsec_…, only this once. Store it in your secret manager right away, and keep it out of code and logs.

curl -X POST https://api.boxline.dev/v1/webhooks \  -H "x-api-key: $BOXLINE_API_KEY" \  -H "content-type: application/json" \  -d '{"url": "https://example.com/webhooks/boxline", "events": ["session.stopped", "agent_run.finished"]}'# The answer holds "secret": "whsec_…", shown only this once.

GET /v1/webhooks lists your endpoints (never their secrets), and PATCH and DELETE /v1/webhooks/:id change and delete one. Deleting it removes its queue and delivery log too. See the API reference.

Verify every delivery

Anyone can send a POST to your URL, so check each delivery’s signature before you trust it. The Boxline-Signature header is t=<unix seconds>,v1=<hex HMAC-SHA256>, where the HMAC is of <t>.<raw body>, keyed with your secret (the whole whsec_… string).

  1. Take the raw request body, exactly as it arrived. Parsing the JSON and writing it out again changes the bytes, and the signature no longer matches.
  2. Refuse a t more than 5 minutes from your clock. That stops old deliveries being replayed.
  3. Compare the HMAC with each v1 in constant time, and refuse the delivery if none matches.

The SDKs do all three: verifyWebhook(body, header, secret) in Node and verify_webhook(body, header, secret) in Python. They return the event, or throw WebhookSignatureError with a reason (malformed, timestamp_out_of_range or no_matching_signature). They make no network request, so they work in any web framework.

import { verifyWebhook, WebhookSignatureError, type WebhookEvent } from "@boxline/sdk";// body: the raw request body (a Buffer or string) exactly as it arrived; header: its Boxline-Signature.export function receive(body: Buffer, header: string | undefined): { status: number; event?: WebhookEvent } {  try {    const event = verifyWebhook(body, header, process.env.BOXLINE_WEBHOOK_SECRET!); // within 5 minutes by default    return { status: 200, event };  } catch (err) {    if (err instanceof WebhookSignatureError) return { status: 400 }; // err.reason: malformed, timestamp_out_of_range, no_matching_signature    throw err;  }}

The window is 300 seconds; { toleranceSeconds } (Python: tolerance_seconds) changes it. webhookSignatureHeader(secret, body) (Python: webhook_signature_header) signs a body the same way, for your receiver’s tests.

Without an SDK

In another language, or without the SDK, the check is short:

import { createHmac, timingSafeEqual } from "node:crypto";export function verifyWebhook(body: Buffer | string, header: string, secret: string, toleranceSeconds = 300) {  const pairs = String(header).split(",").map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]);  const t = Number(pairs.find(([k, v]) => k === "t" && /^\d+$/.test(v))?.[1]);  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) throw new Error("timestamp out of range");  const want = createHmac("sha256", secret).update(`${t}.`).update(body).digest();  const ok = pairs.some(([k, v]) => {    const got = Buffer.from(k === "v1" ? v : "", "hex");    return got.length === want.length && timingSafeEqual(got, want);  });  if (!ok) throw new Error("no matching signature");  return JSON.parse(body.toString());}

Handle each type

The SDKs type every event’s data. WebhookEventPayload is a union keyed by type: in Node, cast the verified event to it (verifyWebhook(…) as WebhookEventPayload) and a switch narrows event.data; in Python, it is boxline.types.WebhookEventPayload, a union of typed dicts. Switch on type, and let the default case ignore the rest:

import { verifyWebhook, WebhookSignatureError, type WebhookEventPayload } from "@boxline/sdk";const handled = new Set<string>(); // keep handled event ids in your database// body: the raw request body exactly as it arrived; header: its Boxline-Signature. Returns the status to answer.export function receive(body: Buffer, header: string | undefined): number {  let event: WebhookEventPayload;  try {    event = verifyWebhook(body, header, process.env.BOXLINE_WEBHOOK_SECRET!) as WebhookEventPayload;  } catch (err) {    if (err instanceof WebhookSignatureError) return 400;    throw err;  }  if (handled.has(event.id)) return 200; // delivered twice: already handled  handled.add(event.id);  switch (event.type) {    case "agent_run.finished": {      const run = event.data; // typed: status, errorCode, resumable, variableNames, usage…      if (run.resumable) console.log(`run ${run.id} stopped at ${run.errorCode}: resume it before ${run.resumable.until}`);      else console.log(`run ${run.id} ${run.status}`, run.resultText);      break;    }    case "session.expiring":      console.log(`session ${event.data.sessionId} stops in ${event.data.secondsLeft} s: give it a longer timeout now if it should go on`);      break;    case "usage.limit_reached":      console.log(`limit ${event.data.kind} reached (${event.data.used} of ${event.data.limit}), resets ${event.data.resetsAt}`);      break;    case "credential.changed":      console.log(`${event.data.type} ${event.data.name} ${event.data.action} by ${event.data.by}`); // never a value      break;    case "credential.code_needed":      console.log(`a run waits for a ${event.data.type} for ${event.data.credential}: send it now`); // never the code or link      break;    default:      break; // a type this receiver does not handle, or one added later: ignore it, and still answer 200  }  return 200;}

Rotate the secret

PATCH /v1/webhooks/:id with { "rotateSecret": true } makes a new secret. It is shown only in that answer, as secret, so store it right away. For 24 hours, deliveries carry two signatures: a v1 with the new secret first, then one with the old. The answer’s previousSecretExpiresAt says when the old one stops signing.

Give your receiver both secrets, roll it out, then drop the old one. The SDKs’ verify functions take a list: [newSecret, oldSecret]. You can send rotateSecret together with other changes, such as a new url. See Update a webhook endpoint.

If the old secret may have leaked, take it out of your receiver as soon as the new one is in place. Boxline keeps signing with it for 24 hours, but your receiver decides which signatures it accepts.

curl -X PATCH https://api.boxline.dev/v1/webhooks/$WEBHOOK_ID \  -H "x-api-key: $BOXLINE_API_KEY" \  -H "content-type: application/json" \  -d '{"rotateSecret": true}'# The answer holds the new "secret" (shown only now) and "previousSecretExpiresAt".

Drop duplicates

A delivery can arrive more than once, for example when your answer got lost. Keep the id of each event you have handled, and skip an event you have seen. Take the id from the verified body, not from the Boxline-Event-Id header, which is not signed.

Retries and backoff

  • Any 2xx answer means delivered. Answer fast, and do slow work after answering.
  • Anything else is tried again: 3xx, 4xx and 5xx answers, no answer within 10 seconds, and network or TLS errors. Redirects are never followed.
  • The next tries come about 1 minute, 2.5 minutes, 6 minutes, 16 minutes, 40 minutes, 1.6 hours, 4 hours, 8 hours and 8 hours later (each give or take 20%). That is at most 10 attempts, the last about 24 hours after the event.
  • Each attempt is signed afresh, with a new t.

When an endpoint is switched off

  • An answer of 410 Gone switches the endpoint off at once (disabledReason: "gone"). Its queued deliveries fail, and your other endpoints get a webhook.disabled event.
  • An endpoint whose deliveries fail for 3 days without a success is switched off too ("failing"), with a webhook.disabled event.
  • A password reset of an account whose email was not confirmed yet switches off the endpoints of all the projects in the organizations it owns and in its personal one ("account_recovered"), in case someone else set the account up. Their queued deliveries fail.
  • Switch it on again with PATCH { "enabled": true }. It starts afresh, with its failures forgotten. { "enabled": false } switches it off yourself ("user").
curl -X PATCH https://api.boxline.dev/v1/webhooks/$WEBHOOK_ID \  -H "x-api-key: $BOXLINE_API_KEY" \  -H "content-type: application/json" \  -d '{"enabled": true}'

Paused after timeouts

An endpoint that times out 3 times in a row is paused, so a slow receiver does not tie up the queue. pausedUntil shows until when: 1 minute at first, doubling with each further timeout, at most an hour. Its deliveries wait meanwhile; test events still go. Any answer ends the pause.

The delivery log

GET /v1/webhooks/:id/deliveries lists an endpoint’s deliveries, newest first, and ?status= filters them (pending, delivered or failed). Each has its attempts, a short history, the last answer’s status, time and first 1 KB, and an errorCode. Entries are kept 30 days, and the body that was sent 7 days.

POST /v1/webhooks/:id/deliveries/:deliveryId/retry sends a finished delivery again now, with the same event id and body. It waits up to 12 seconds for your answer.

# Failed deliveries, newest first, with each attempt.curl "https://api.boxline.dev/v1/webhooks/$WEBHOOK_ID/deliveries?status=failed" \  -H "x-api-key: $BOXLINE_API_KEY"# Send one again now, with the same event id and body.curl -X POST https://api.boxline.dev/v1/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/retry \  -H "x-api-key: $BOXLINE_API_KEY"
errorCodeMeaning
bad_statusYour endpoint answered, but not with a 2xx.
goneIt answered 410: the endpoint is switched off.
timeoutNo answer within 10 seconds.
connection_failed, tls_failedThe connection, or its TLS, failed.
address_not_allowedThe name now resolves to an address that is not public.
gateway_unavailableThe platform's outgoing gateway could not be reached.
endpoint_disabled, project_suspended, payload_expired, queue_full, signing_failedNever sent: the endpoint was off, the project suspended, the body too old, the queue full (10,000 waiting), or signing failed.

Send a test event

POST /v1/webhooks/:id/test sends a webhook.test event to that endpoint only, signed like every delivery, also while the endpoint is switched off. With { "type": "agent_run.finished" } (any type but "*"), it sends a made-up sample of that event instead, in the real shape, with ids that say “sample” and "test": true in the body: the samples above. It makes one attempt and waits up to 12 seconds for your answer, which the result shows. A test never counts toward switching the endpoint off, except a 410 answer.

# webhook.test, or with "type" a made-up sample of that event (its body has "test": true):curl -X POST https://api.boxline.dev/v1/webhooks/$WEBHOOK_ID/test \  -H "x-api-key: $BOXLINE_API_KEY" \  -H "content-type: application/json" \  -d '{"type": "agent_run.finished"}'

Local development

This receiver verifies every delivery, drops duplicates and answers at once. Start it with the secret of an endpoint you made:

Shell
BOXLINE_WEBHOOK_SECRET=whsec_… npx tsx receiver.ts   # or: python receiver.py
import { createServer } from "node:http";import { Boxline, WebhookSignatureError, verifyWebhook } from "@boxline/sdk";const port = 4900;const url = `http://127.0.0.1:${port}/webhooks`;// The endpoint's secret; after a rotation, both: "whsec_new,whsec_old".let secrets = (process.env.BOXLINE_WEBHOOK_SECRET ?? "").split(",").filter(Boolean);const bx = new Boxline();let registered: string | undefined;if (process.argv.includes("--register")) {  // Local development only: your local API must run with WEBHOOKS_ALLOW_LOCAL=1. Real endpoints are public HTTPS.  const endpoint = await bx.webhooks.create({ url, events: ["session.stopped", "agent_run.finished", "crawl.finished"] });  secrets = [endpoint.secret]; // kept in memory, never printed  registered = endpoint.id;}if (!secrets.length) throw new Error("set BOXLINE_WEBHOOK_SECRET, or pass --register");const handled = new Set<string>(); // event ids already handled; a real receiver keeps them in its databaseconst server = createServer((req, res) => {  if (req.method !== "POST" || req.url !== "/webhooks") return void res.writeHead(404).end();  const chunks: Buffer[] = [];  req.on("data", (c: Buffer) => chunks.push(c));  req.on("end", () => {    const body = Buffer.concat(chunks); // the raw bytes: re-serialised JSON would not match the signature    try {      const event = verifyWebhook(body, req.headers["boxline-signature"] as string | undefined, secrets);      if (!handled.has(event.id)) {        handled.add(event.id); // a delivery can arrive more than once: drop the ones you have handled        console.log(event.type, event.id, JSON.stringify(event.data).slice(0, 120));      }      res.writeHead(200).end("ok"); // answer fast; do slow work afterwards    } catch (err) {      if (!(err instanceof WebhookSignatureError)) throw err;      res.writeHead(400).end(err.reason);    }  });});server.listen(port, "127.0.0.1", () => console.log(`listening on ${url}`));process.on("SIGINT", async () => {  if (registered) await bx.webhooks.delete(registered); // clean up the local endpoint  process.exit(0);});

With --register, the receiver makes its own endpoint for http://127.0.0.1:4900/webhooks, keeps the secret in memory, and deletes the endpoint when you stop it with Ctrl+C. That needs an API running on your own computer (BOXLINE_API_URL=http://localhost:8080) started with WEBHOOKS_ALLOW_LOCAL=1.

Limits

  • Your plan allows a number of endpoints, limits.webhookEndpoints in GET /v1/auth/me. The starting values are 2 on Free, 10 on Hobby, 50 on Startup and 200 on Scale. Beyond that, a new endpoint is 402 plan_limit. See Plans and features.
  • Creating, changing, deleting and rotating: 60 a minute per project. Tests and retries: 10 a minute per project. See Rate limits.
  • At most 10,000 deliveries can wait per project; beyond that new ones fail at once (queue_full). The queue is fair: one project’s slow receiver cannot hold up another’s.
  • Nothing is queued for a suspended project, and its queued deliveries fail while it is suspended.

Security

  • Public HTTPS only. URLs that are http://, IP addresses, private, loopback, link-local (cloud metadata), CGNAT or reserved addresses, internal names (localhost, .local, .internal, cluster names, names without a dot), mail ports (25, 465, 587, 2525), or a URL with a user name or password are refused with 400 webhook_url_not_allowed.
  • A name is resolved when you save the endpoint, where every address must be public, and again for every connection, which then goes to the address that was checked.
  • Deliveries leave through the platform’s outgoing gateway, never straight from an API server.
  • Verify every delivery, drop duplicates, and treat the event as a signal: fetch anything you act on from the API.
  • Keep the signing secret on your server. Rotate it if it may have leaked.

Errors

StatusCodeWhen
400webhook_url_not_allowedThe URL is not public HTTPS: see Security below.
402plan_limitYour project has as many endpoints as its plan allows.
409conflictAnother request rotated the secret at the same moment. Try again.
409invalid_stateRetry: the delivery is still being tried. Wait for its next attempt.
409payload_expiredRetry: the event's body is kept 7 days only.
409webhook_disabledRetry: the endpoint is switched off. Switch it on first.
429rate_limitedEndpoint changes: 60 a minute; tests and retries: 10 a minute, per project.
503webhooks_unavailableWebhooks are not set up on this API server. Try later.

The SDKs raise WebhookUrlNotAllowedError, WebhooksUnavailableError, WebhookDisabledError and PayloadExpiredError for these.

These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.