← All docs

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 (sig contains bot) is refused the room with QueueDenied — and so is a token whose action is block: 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's holdScore, 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 sets bindIp, 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 opensAt are reordered at open time by hash(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 × ratePerMinute and 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. etaSeconds counts from the opening when the room hasn't opened yet; a held spot, and every spot while the room is paused, answers null rather 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. opensAt defaults to now; a future opensAt is 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 an open room pauses (400 otherwise), and visitors are not told: while paused, a spot answers waiting with position and etaSeconds null. Resume is open again — counters and shuffle carry over, and opensAt is ignored: the clock picks up where it stopped.
  • {"action": "close"} — expires every live spot, resets the counters and returns the room to idle. 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 429 by 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.