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
402inside the normal success envelope, so readdata.allowedrather 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.
| Mechanism | Deadline | Releases |
|---|---|---|
| Reservation TTL | 2 minutes | A launch that was authorized but never reported a session — a display collision, a dropped socket, a worker that died mid-boot |
| Heartbeat reconciliation | 60 seconds (VEIL_SESSION_HEARTBEAT_SEC) | Anything the worker no longer lists as running. Immediate, not a timeout |
| Heartbeat TTL | 5 minutes | Sessions from a worker that was reporting and went silent |
| Unreported backstop | 6 hours | Sessions 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.
# 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.
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 reservationDELETE /v1/profiles/:id/sessions/:sessionId— session endedPOST /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.neteven inside an otherwise isolated network. Budget one cloud round-trip per launch. - Slot reservation is cloud-side only. The worker receives a
slotIdbut 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
- Architecture — where licensing fits in the tiers, and what crosses the trust boundary.
- Troubleshooting — what a
402means and how to clear it. - API reference — every endpoint on this surface.