VeilBrowserDocs

Licensing & concurrency

How self-hosted workers enforce plan concurrency through the cloud control plane, and how capacity is reclaimed when a worker dies.

Even when the stack is self-hosted, the cloud control plane remains the authority on your plan and organization-wide concurrency. A worker asks permission before every launch and fails closed if it cannot get an answer.

The part worth understanding is not the check itself — it is what happens to capacity a worker stops accounting for. Three mechanisms release it, at three different speeds.

Organization API keys

Workers authenticate to the cloud with an organization API key (VEIL_KEY) issued from your account. The key identifies the organization and scopes what the worker may request. It is the only credential the licensing surface accepts, which is deliberate: the party that needs to reconcile abandoned sessions is the worker, or the operator holding its key, and neither has a dashboard session.

Authorize before launch

Before launching a browser, a worker calls POST /v1/license/authorize with the organization key. The cloud counts what the organization is using and compares it against the plan's limit.

What it counts is running sessions plus live reservations. Counting only sessions left a window in which simultaneous launches each observed the same free capacity, which made the limit advisory rather than enforced. So authorize takes out a reservation — a LicenseSlot — inside a per-organization lock, and returns its slotId.

  • Concurrency is organization-wide, not per-host. A second cluster does not get its own allowance.
  • Authorization is fail-closed: if the cloud denies the request or is unreachable, the worker does not launch. Sessions already running are never killed.
  • A refusal is 402 inside the normal success envelope, so read data.allowed rather than the status alone. See Troubleshooting.

How capacity comes back

Every one of these exists because capacity that is held forever is indistinguishable from capacity you are not paying for. A single recreated test worker was enough to pin an organization at current: 5, limit: 3 and refuse every launch.

MechanismDeadlineReleases
Reservation TTL2 minutesA launch that was authorized but never reported a session — a display collision, a dropped socket, a worker that died mid-boot
Heartbeat reconciliation60 seconds (VEIL_SESSION_HEARTBEAT_SEC)Anything the worker no longer lists as running. Immediate, not a timeout
Heartbeat TTL5 minutesSessions from a worker that was reporting and went silent
Unreported backstop6 hoursSessions from a worker that has never reported liveness at all

The reservation

A reservation holds capacity for two minutes, long enough to outlast a slow launch — profile download, browser boot — and no longer. Reporting the session start consumes it, after which the ProfileSession row carries the count in its own right. An expired reservation is swept, and if the launch was launch-by-criteria, the ad-hoc profile it created is retired with it: a one-time profile with no session against it, holding quota that nothing else would ever release.

The heartbeat

POST /v1/license/sessions/heartbeat is the worker saying "these are the browsers I actually have", for one machine. Sessions it names have their deadline pushed out; everything else attributed to that machine is released immediately. That is what makes a killed browser recoverable in seconds rather than hours. Workers send it every VEIL_SESSION_HEARTBEAT_SEC (default 60; 0 disables it, which drops the worker back to the six-hour backstop).

Sessions started in the last minute are exempt, so a launch that races the worker's enumeration is not written off for missing a list it predates.

The two TTLs

A worker that reports liveness proves it can, so its sessions answer to a short deadline: five minutes of silence and they are presumed dead. A session from a worker that has never reported gets six hours, because there is no signal separating a long-lived browser from an abandoned row, and reaping a live one undercounts the organization. The deadline tightens by itself, per worker, the moment that worker starts heartbeating.

Reaped sessions are recorded as crashed, not stopped — they did not end, they were written off, and the sessions list says so.

Reclaiming by hand

POST /v1/license/sessions/reclaim ends running sessions whose worker is gone, without waiting out a deadline. It is idempotent and scoped to the key's organization, so it grants nothing the per-session delete does not.

terminal
# Everything past its deadline
curl -X POST https://api.veilbrowser.net/v1/license/sessions/reclaim \
  -H "X-API-Key: $VEIL_KEY"

# One machine, with an explicit age cutoff instead of the deadlines
curl -X POST "https://api.veilbrowser.net/v1/license/sessions/reclaim?machineId=worker-1&olderThanMinutes=30" \
  -H "X-API-Key: $VEIL_KEY"

Inspecting what you are being charged for

GET /v1/license/sessions answers with the same key, which matters: the dashboard session list needs a browser login, and an operator holding only a worker key could previously see the 402 with no way to find out which rows caused it.

terminal
curl -fsS https://api.veilbrowser.net/v1/license/sessions \
  -H "X-API-Key: $VEIL_KEY"

It returns current, limit, the split into runningSessions and reservedSlots, both TTLs, and up to 500 running sessions with machineId, startedAt, heartbeatAt and a stale flag. stale is computed the same way the reaper decides, so what you read is what a reclaim would act on.

Session start and end

After a successful authorize and launch, the worker reports session lifecycle:

  • POST /v1/profiles/:id/sessions — session started; consumes the reservation
  • DELETE /v1/profiles/:id/sessions/:sessionId — session ended
  • POST /v1/license/sessions/heartbeat — still running

Those rows power the dashboard activity list (GET /v1/sessions) and the running-session count authorize reads.

Limits and unlimited plans

Plan limits use an explicit null = unlimited convention. Enterprise custom plans can set unlimited profiles and concurrency while still being metered for visibility.

Launch-by-criteria also consumes profile quota, because it creates a profile, and is checked against profileLimit in the same lock. A refusal there comes back with reason: "profile_limit" rather than "concurrency_limit".

What this does not do

Two real limits, worth knowing before you design around the gate:

  • There is no offline grace window. A worker that cannot reach the control plane cannot launch, full stop. Nothing lets a browser start while verification is unavailable, so workers need reliable outbound HTTPS to api.veilbrowser.net even inside an otherwise isolated network. Budget one cloud round-trip per launch.
  • Slot reservation is cloud-side only. The worker receives a slotId but has no route that releases it, so a failed launch waits out the two-minute TTL rather than handing capacity back. The worker also cannot see reservations it did not make.

A launch made with another tenant's per-launch key falls back to the slow deadline: the reservation is attributed to the machine that authorized, and the session is attributed elsewhere, so nothing consumes the slot and nothing heartbeats the row.

Next steps

Was this page helpful?

On this page