Virtual waiting rooms
A Queue-it-style waiting room, native to the detection engine: when demand exceeds what your site can absorb, visitors take a number and wait — and because a spot in line is a verdict with a ticket on it, the queue itself is protected by the same signals as everything else.
The differences from bolting a waiting room next to a bot product:
- Bots don't queue-jump here — they don't queue at all. Entering
requires a live session token, so every spot has an identification behind
it. A token whose signal breakdown convicted automation (
sigcontainsbot) is refused the room withQueueDenied— and so is a token whose action isblock: the edge refuses that token whatever admission it holds, so its place in line could only take a drain slot from a real visitor. A hot-but-unconvicted score (at or above the room'sholdScore, default 70) is held: it keeps a ticket, polls like everyone else, and the drain never reaches it. - One spot per visitor, structurally.
(room, visitor)is a unique index. A second tab, a refreshed page, a second browser profile behind the same fingerprint — all are handed their own position back, not a second place in line. Queue-it sells anti-hoarding as an add-on; with a device fingerprint already in the verdict, it is a constraint, not a feature. - Admission is bound, not bearer. The admission token is a
session token with three extra claims:
q(the room),qj(the exact session that earned the spot) and, when the room setsbindIp,qi(the entering network's tag). A scalped ticket presented from another session — the classic bot-waits-then-hands- over-the-cookie play — refuses at the edge. - The spot lives and dies with the session. Polls re-check revocation:
a session the pulse condemns mid-queue loses its place on the next beat.
And an admission is its token's lifetime — a minute after the token
lapses, the spot expires and polls answer
410. The admission is not dealt to anyone else: the drain counts admissions dealt, not admissions spent.
The fairness model
- Pre-queue shuffle. Entries created before
opensAtare reordered at open time byhash(shuffleSeed, entryId)— arriving early earns a place in the lottery, not a better place in line, which removes the incentive to hammer the door. The seed regenerates on every opening, so the order cannot be predicted or rehearsed. - FIFO after. Arrivals once open queue strictly by ticket number, behind the whole shuffled pool.
- Lazy drain. There is no cron: every enter/poll recomputes
target = elapsed × ratePerMinuteand admits the head of the line until what was admitted, and what was forgiven, reaches it. The counter only moves forward, so pausing the room freezes admissions and resuming picks up from the clock — never a burst. Nor does a quiet spell end in one: what the clock earns while nobody is waiting is forgiven when the line runs dry, so a room left empty for half an hour at 60 a minute does not let the next 1,800 arrivals straight in. - Honest ETA.
etaSecondscounts from the opening when the room hasn't opened yet; a held spot, and every spot while the room is paused, answersnullrather than a lie.
Rooms
Rooms are per environment (a staging rehearsal cannot drain the production
event) and managed through the Management API with the
queues:read / queues:write scopes:
GET | POST /api/management/v1/queues?environment=production
GET | PATCH | DELETE /api/management/v1/queues/{queueId}
Config: slug (3–64 lowercase letters, digits and dashes; fixed once
created), name, ratePerMinute (admissions a minute, 1–100,000, default
60), holdScore (1–100, default 70), admissionTtlSec (30–3,600, default
300), bindIp (admissions carry a tag of the network that entered — its
IPv4 address or IPv6 /64, where a phone's address changes — and the edge, and
a server SDK's verifyAdmission handed the client's address, refuse them from
anywhere else: the ticket-resale brake for networks; a
visitor entering with no address the server could see is refused with
AddressRequired, since an admission bound to nothing would pass anywhere),
redirectUrl (where the hosted page sends admitted visitors, token in
?abq=). A room's status is idle, open or paused. PATCH takes
config fields (all but slug), an explicit action, or both:
{"action": "open", "opensAt": <epoch ms>}— starts (or schedules) the clock and reseeds the shuffle.opensAtdefaults to now; a futureopensAtis the classic "sale starts at noon" pre-queue. A past one is taken as given: the clock already owes every minute since, and the head of the line is admitted at once.{"action": "pause"}— freezes admissions and the clock: the line keeps forming, but the drain earns nothing while paused, so resuming never produces a catch-up burst. Only anopenroom pauses (400otherwise), and visitors are not told: while paused, a spot answerswaitingwithpositionandetaSecondsnull. Resume isopenagain — counters and shuffle carry over, andopensAtis ignored: the clock picks up where it stopped.{"action": "close"}— expires every live spot, resets the counters and returns the room toidle. An admission token already dealt is a signed receipt the edge checks without asking: it stays good until its own expiry (admissionTtlSec, an hour at most). To stop admitted visitors too, shorten the TTL before the event or revoke their sessions.
GET /queues/{id} returns live stats and, while the room is open, the line
as the drain sees it (ticket, position, visitor, score — waiting spots,
the first 200) — a queue you cannot inspect is a queue you
cannot defend to the customer insisting they were there first. Every
mutation lands in the audit log (queue.created, queue.updated,
queue.deleted).
The visitor flow
identify ──► session token ──► POST /api/v4/queue/enter ──► waiting + ticket
│ + queueToken
│ + position/ETA
▼
POST /api/v4/queue/poll (with queueToken)
│
▼
admitted ──► admission token (ab1.…, claims.q = room)
POST /api/v4/queue/enter — { apiKey, room, sessionToken }. Answers
idle (room not open: never opened, or closed — no ticket is taken),
waiting (also in a paused room), held, or admitted (room open and
the clock already owes this spot), with room, roomName, redirectUrl,
ticket, position, etaSeconds, pollAfterMs, queueToken and
token. The door refuses with QueueDenied, SessionRevoked,
AddressRequired (a bindIp room, no address), RoomNotFound,
TokenExpired or TokenInvalid — see
Errors. The ticket number alone authorizes
nothing; polling presents the signed queueToken (qt1.…), so a
guessable ticket number is not a way to watch or steal a place.
POST /api/v4/queue/poll — { apiKey, ticket }, where ticket is the
queueToken, not the ticket number. Heartbeats the spot (a
spot nobody polls for a day expires), runs the drain, and answers in the
same shape as enter: the position, ETA, suggested pollAfterMs, and —
once dealt — the admission token. A 410 TicketGone means re-enter.
From the browser agent
const result = await antibot.enterQueue("spring-drop", {
onUpdate: ({ position, etaSeconds }) => render(position, etaSeconds),
})
if (result.status === "admitted") {
// Hand result.token to your backend / edge; it verifies offline.
}
enterQueue identifies first if the agent holds no live token, polls at
the server's suggested cadence, re-enters if the spot expired, and
resolves on admission — or at once, with status: "idle", when the room
is not open. Pass an AbortSignal to cancel the wait.
The hosted waiting room
/queue/<slug>?key=<public key> is a ready-made page: it runs the agent,
takes the visitor's verdict to the door, shows position and ETA, and
redirects admitted visitors to the room's redirectUrl with the token in
?abq=. It goes through the public agent like any integrator page — the
hosted room is a customer of enterQueue, not a private channel.
Verifying admissions
Wherever the queued traffic lands, require the receipt — and bind it to the session presenting it:
const admission = verifyAdmission(tokenFromQueryOrHeader, sessionKeys, { room: "spring-drop", clientIp: request.ip })
const session = verifySessionToken(sessionCookie, sessionKeys, { userAgent })
if (
!admission.valid ||
!session.valid ||
admission.claims.qj !== session.claims?.jti || // earned by *this* session
admission.claims.vid !== session.claims?.vid
) deny()
An admission is signed with a key of its own, derived from the session key
(HMAC-SHA256(sessionKey, "antibot-admission-v1"), base64), so a receipt is
never taken for an admission nor an admission for a receipt: verifySessionToken
refuses an admission as it refuses a forgery. Spent as a receipt, an admission
would carry no client binding and live the room's admission window, and at the
door it would re-key its spot to itself — the resale brake undone by one stolen
token.
Three claims make an admission:
| Claim | Meaning | The attack it kills |
|---|---|---|
q |
the room this admits to | a clean verdict token spent as an admission; a ticket for another event |
qj |
the session-token id the spot was earned with | ticket resale: a scalped admission presented beside any other session refuses; both machines pulsing at once would also condemn the session |
qi |
a keyed tag of the entering network (rooms with bindIp only): its IPv4 address or IPv6 /64, HMAC under the admission key, so no address can be read back from it. Checked by the edge, and by verifyAdmission when it is given the client's address (clientIp; left out, it is not) |
checkout from a different network than the one that queued |
q is what separates an admission from a verdict: a visitor with a clean
identification and no wait still cannot enter the drop without one. Tokens
expire after the room's admissionTtlSec (default 5 minutes) — an
admission not spent promptly is spent by no one, and the spot itself
expires with the token (a minute after it lapses, polls answer 410). For
single-use semantics at a redemption step, spend the
token's jti in a replay cache.
At the edge
The proxy enforcer checks all of it offline. Configure the room alongside the usual enforcement settings and the queued path requires session + bound admission, refusing (or redirecting to the waiting room) otherwise:
ANTIBOT_ENFORCE_PATHS=/api/checkout,/drops
ANTIBOT_ENFORCE_QUEUE=spring-drop
ANTIBOT_ENFORCE_QUEUE_REDIRECT=https://shop.example.com/queue/spring-drop
ANTIBOT_ASSESS_SECRET_KEY=sk_live_…
Set ANTIBOT_ASSESS_SECRET_KEY too. An organization's environments share its
session keys, and an admission names its room by slug: only an edge that
knows its own environment (from the revocation list the key fetches) refuses
a staging session and its admission to a staging room of the same slug.
Without it, the edge says so when it starts. Otherwise, give each
environment's rooms their own slugs.
The edge reads the admission from the _abq cookie. The hosted room hands
it over in ?abq= on redirectUrl; the edge takes it from there itself. An
?abq= admission is held to every check below, then set as the _abq cookie
(HttpOnly; Secure; SameSite=Lax, for the admission's remaining life) and
the visitor sent round (307) to the same URL without it — so the token
leaves the address bar, the history and every Referer after it. One that
fails a check is refused like any other, and no cookie is set. Without the
edge, the page at redirectUrl must set that cookie itself.
Refusal reasons land in the antibot-enforcement header:
queue:no-admission, queue:admission-<why> (an admission that is
malformed, forged or expired — queue:admission-expired, say),
queue:wrong-room, queue:session-mismatch,
queue:visitor-mismatch, queue:ip-mismatch.
How long a line can be
A spot's place in the drain order is set when it is taken — the shuffle's draw if the room has not opened, its ticket if it has — and kept in an index, so a poll reads the line whole only to retake its marks (below), one poll a room every 30 seconds at most:
- Positions are counted from the front, up to a thousand: exact, and the front is where a second matters. Further back the room keeps marks — the spot standing at every thousandth place, retaken by one request at most every 30 seconds — and a spot counts only from the mark before it. The drain takes from the front, so a mark's place is where it stood less those admitted since; a spot that leaves the line another way (expired, held) is missed by the places behind it until the next marks, half a minute at most.
- The drain reads the head of the line in order and admits at most 200 in one batch; a room owed more admits the rest on the next poll.
- Polls are suggested every second within ten of the front, 2.5 s within
a hundred, 5 s within a thousand and 15 s beyond; a ticket may poll 90
times a minute, so a spot that polls as told is never refused, and the
agent answers a
429by waiting longer, never by leaving the line. A spot's last-seen time is written once every five minutes, not on every poll. - The sweep that expires abandoned spots runs at most once a minute per instance, as bounded statements on an index.
Measured on a development machine (local SQLite) with 100,000 in line: a poll costs 1.2–1.3 ms wherever the spot stands — reading the line whole, as it was, cost 2.6 s at the middle — a drain of 200 costs about 120 ms, and retaking the marks about 160 ms, once every half minute per room. A remote database adds its round trip to each statement.
Adversarial notes, honestly
What the queue stops, structurally. Bots refused at the door (sig
convictions), hot scores held forever unless an operator releases them,
one spot per visitor (the unique index — tabs, refreshes and second
profiles collapse to one place), positions unforgeable and unwatchable
(HMAC ticket tokens), pre-queue order unpredictable (per-opening seed),
pauses that cannot be turned into catch-up bursts, spots that die when
their session is condemned mid-queue, admissions that lapse with their
token, and admissions that refuse to travel without their session (qj)
or network (qi).
What it cannot stop. A farm running N genuinely clean sessions — real browsers, real fingerprints, human-solved challenges — earns N spots, because each spot is honestly one visitor as far as the evidence goes. That is the hard floor of every waiting-room product, Queue-it included: the queue's gate is the detection engine, and the economics are the point — every signal that fires (velocity, datacenter, cadence, tampering) raises the cost of each clean identity. What the queue does guarantee is that N spots cannot become more than N checkouts: admissions don't clone, don't transfer, and don't outlive their window.
Operational levers for hot events. Lower holdScore to hold more
borderline sessions (release them later by raising it — release is live),
shorten admissionTtlSec so unused spots return to the line quickly, and
enable bindIp when the audience is not roaming (drops and presales, yes;
mobile-first audiences, think twice). bindIp binds the admission to the address
the spot entered from; an entry that arrived with no address the server could
read (a misconfigured ANTIBOT_CLIENT_IP_HEADER, see the
production checklist) gets an admission without the
tag, good from anywhere.