The addresses on this page were filled in with this server's own address, https://qm.weekday100.com.
Queue Manager — Integration Guide
Protect any page of your site with a virtual waiting room by adding one script tag. No build step, no npm package, no backend change required for the basic setup. A server-side option is documented below for pages that must never be reachable without a valid pass.
Quickstart (60 seconds)
- Create a room — in the admin dashboard (served at
https://qm.weekday100.com/, sign in with yourADMIN_KEY), or with one curl:
curl -s -X POST "https://qm.weekday100.com/api/admin/rooms" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"checkout","name":"Checkout","ratePerMinute":120,
"targetUrl":"https://shop.example.com/"}'
targetUrl is not optional garnish — it is the room's destination, and it puts the site's own origin on both allowlists (see Which origins a room allows). A room created without it queues and promotes perfectly and then strands every visitor it lets through: qm_return is refused because nothing is allowed, and there is no destination to fall back on. GET /api/v1/verify?roomId=checkout fails on return_policy for exactly this reason.
- Add this to the
<head>of every page you want protected — before any other script, so a queued visitor is redirected before the page does anything else:
<script async
src="https://qm.weekday100.com/snippet/qm.js"
data-qm-server="https://qm.weekday100.com"
data-qm-room="checkout"></script>
Replace qm.weekday100.com with your Queue Manager host and checkout with your room id — or skip the editing entirely: the room's Install button in the dashboard hands you the same tag with your own host and room id already filled in, next to a link to the room's live waiting page. That's it:
- When the room is active and the visitor has no valid pass, they are redirected to the waiting room and automatically sent back to the exact page they came from once it's their turn.
- When the room is bypass (or deleted), everyone passes — the tag is inert.
- If the queue server is unreachable, everyone passes (see Fail-open).
The script is async: it never blocks page rendering. Measured cost on the wire, as served by this deployment:
| Encoding | Bytes on the wire |
|---|---|
Content-Encoding: br (Chrome, Edge, Firefox, Safari 16.4+) | 5,220 |
Content-Encoding: gzip | 6,114 |
Content-Encoding: deflate | 6,102 |
uncompressed (client sent no Accept-Encoding) | 15,865 |
Check it yourself against your own deployment — this is the number the response actually carries, not the source file's size:
curl -s -o /dev/null -w '%{size_download} bytes\n' \
-H 'Accept-Encoding: gzip' https://qm.weekday100.com/snippet/qm.js
/snippet/qm.js is served with Vary: Accept-Encoding and a per-encoding ETag, and answers If-None-Match with a bodyless 304, so a returning visitor pays one conditional request and no body at all. Cache-Control is no-cache (revalidate every time, never serve stale), so an upgrade you deploy reaches every protected page on the visitor's next pageview.
How it works
In words: (1) a visitor opens a protected page; (2) the tag asks the queue (/api/check) whether they hold a valid pass, and if they do the page simply loads; if not, (3) they wait in the waiting room, which shows their live place and ETA; (4) their turn comes up, in ticket order; (5) they are sent back to the page they were on, carrying a signed pass (qm_token); (6) the tag keeps the pass, and its next check says pass. The same flow, with the requests spelled out:
Customer site (shop.example) Queue Manager (qm.weekday100.com)
──────────────────────────── ────────────────────────────────
1. Page loads, qm.js runs
│ token? (from ?qm_token=, localStorage, or cookie)
│
├──── GET /api/check?roomId=checkout&token=... ──────────────►
│ room bypass/missing? → pass
│ token signed+passed? → pass
│ otherwise → queue
◄──── {"action":"pass"} or {"action":"queue","waitingRoomUrl":"/w/checkout"}
│
2a. "pass" → nothing happens, visitor browses normally
2b. "queue" → redirect to
https://qm.weekday100.com/w/checkout?qm_return=<current page URL>
│
│ visitor waits: live position,
│ ETA, SSE updates. Engine
│ promotes FIFO at ratePerMinute.
│
3. Turn comes up → waiting room redirects back:
https://shop.example/original-page?qm_token=<signed pass token>
│
4. qm.js stores the token (localStorage + cookie), strips qm_token
from the address bar, re-checks → "pass" → visitor is through.
The pass stays valid for passedTtlSec (default 600 s), so normal
navigation across protected pages does not re-queue.
Fairness guarantees come from the server: strict FIFO by ticket number, and a returning visitor with a stored token keeps their place in line.
Where the visitor lands after the queue
When a visitor is promoted, the waiting room picks the destination in this order:
qm_return— the exact page the visitor was on when the snippet (or your middleware) redirected them. This always wins when present: the snippet on that page absorbs the token and scrubs it from the address bar.- The room's
targetUrl— used only when the visitor arrived at the waiting room directly (bookmarked link, shared URL, noqm_return). Set it to a page that carries the snippet; a token-bearing URL landing on a page without the snippet leaves?qm_token=visible in the address bar and in that origin's access log. - The referring page — last resort, manual "Continue" button only, never auto-redirect.
So it is fine to set a targetUrl on a room that is also protected by the snippet: snippet-redirected visitors still return to the exact page they came from, and targetUrl only catches direct arrivals.
Point each room's waiting room at its own protected pages
⚠️ Passes are room-scoped. A pass minted by roomAmeans nothing to a page whose snippet checks roomB— and nothing anywhere tells you that you got it wrong, because both halves behave exactly as designed: RoomA's waiting room queues the visitor, promotes them, and sends them totargetUrlwith?qm_token=<pass for A>. The snippet on that page is configureddata-qm-room="B", so it checks roomB, holds no pass forB, and redirects into room B's queue. The visitor waits twice, or ping-pongs between the two rooms; roomA's queue looks healthy and empty because it is promoting people. There is no error, no console message, and no failed request — the pass is valid, it is simply valid for a door nobody is standing at. The same trap, in the three shapes it usually takes: | You wired | What actually happens | |---|---| | RoomA'stargetUrl→ a page taggeddata-qm-room="B"| PromotedAvisitors land and are immediately queued byB. | | One page tagged forA, a linked page tagged forB| PassingAdoes not admit them to theBpage; second queue. | | Renamed a room id but left the old id in a deployed tag | The tag names a room that no longer exists →{"action":"pass"}, i.e. the waiting room is off for those pages. | How to catch it: The room id indata-qm-room(and in your middleware'sROOM) must be the same string as the room whose waiting room you send people to. Pages that must share a queue must share one room id; pages that need independent rates get separate rooms and separate waiting rooms. The last row is detectable and is counted for you:GET /api/admin/healthreportsunknownRoomChecks, and Prometheus exposesqm_unknown_room_checks_total{room="..."}. Non-zero means a deployed snippet names a room that does not exist and is protecting nothing. Alert on it. The first two rows are not detectable server-side — both rooms exist and every request is well-formed — so verify them by walking the flow once in a real browser before a drop: join, get promoted, and confirm you land on the protected page without being queued a second time.
Which origins a room allows (tagOrigins, returnOrigins)
A room has two lists of websites. Each answers one question:
| List | The question it answers | Forget a site here, and… |
|---|---|---|
tagOrigins | Which websites may use this room's queue? | visitors on that site skip the queue entirely |
returnOrigins | Which websites may visitors be sent back to when their turn comes? | visitors get stuck on the waiting page |
The site in the room's own targetUrl is always on both lists; you only list the others.
Read top to bottom: a page running the tag must be on tagOrigins for the queue's answer to reach it, or its visitor skips the queue without any error. When the visitor's turn comes, the waiting room sends them back to qm_return only if that site is on returnOrigins; otherwise they go to the room's targetUrl, and with no targetUrl they are stuck on the waiting page. The targetUrl site is on both lists, tagOrigins: null means the same list as returnOrigins, and every extra returnOrigins entry is a site anyone can send your visitors to through your waiting room.
⚠️ Check tagOrigins before every launch. Getting it wrong raises no error anywhere: the site simply serves everyone unqueued while the room looks healthy.
tagOrigins: who may use the queue
Every time a visitor opens a page, the tag on that page asks the queue server "must this person wait?". If the page is on a site that is not in tagOrigins, the server does not let the browser read its answer (the browser's CORS rule). The tag gets no answer, and it is built to let the visitor through rather than break your site when it cannot reach the queue. So:
- the visitor goes straight in, unqueued;
- there is no error page, no redirect, nothing in the browser console you would notice;
- the room card shows a healthy room that simply has no traffic.
That is why a missing entry here is the dangerous mistake: the queue silently stops protecting that site.
List every site the tag runs on. These all count as different sites: https://shop.example.com and https://www.shop.example.com; a second brand domain; a staging host; any port other than the default.
tagOrigins defaults to null, which means "use the same list as returnOrigins" — so a room set up before the two lists existed behaves exactly as it did.
returnOrigins: where visitors may be sent
The waiting page remembers the page each visitor came from (qm_return) and sends them back there when their turn comes. qm_return is part of the URL, so anyone can change it. If the destination is not in returnOrigins, it is thrown away; a server-side connector then leaves the visitor on the waiting page, because it has nowhere safe to send them.
The risk here runs the other way: an extra site on this list lets anyone use your waiting room to forward visitors to that site (an open redirect). So keep it short — only sites you are happy to send visitors to.
Setting them
In the dashboard: Edit room → "Pages allowed to run the tag" (tagOrigins) and "Origins visitors may be returned to" (returnOrigins). Or over the API:
curl -X POST "$QM_SERVER/api/v1/admin/rooms" -H "Authorization: Bearer $ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"id":"checkout",
"tagOrigins":["https://www.shop.example.com","https://landing.shop.example.com"],
"returnOrigins":["https://www.shop.example.com","https://shop.example.com"]}'
Write each site as scheme + host (+ port if not the default), with no path: https://www.shop.example.com, not https://www.shop.example.com/checkout.
Checking them before you ship
Always pass --origin, set to a site the tag runs on:
npx --yes "$QM_SERVER/connectors/node/latest.tgz" verify --server "$QM_SERVER" --room checkout \
--origin https://www.shop.example.com
This runs the checker from the tarball your queue server serves, so it needs no install and never touches the npm registry. Do not shorten it to npx queue-manager: on the registry that name is an unrelated package (QM-489).
Without --origin (or --url, which implies one) the check cannot ask about tagOrigins at all, and will pass an install that only works on one site.
The dashboard's Run check takes the site from the page URL you give it and reports one result — pass, fail or warn:
- Fail — the tag on that site cannot read the queue's answer. The button Allow \<origin\> adds the site to
tagOriginsonly. - Warn — after that fix, on the next run: the site can use the queue but visitors may not be returned to it. A separate button, Also return visitors to \<origin\>, adds it to
returnOrigins. It is a separate step on purpose, because widening where visitors can be sent is a security decision.
You see one of these at a time: the warn cannot appear until the fail is fixed, because until the site can read the queue's answer the check never gets as far as the return question.
Watching them in production
If a page with the tag is on a site the room does not allow, the server counts it: GET /api/admin/health reports refusedOriginChecks (room → origin → count) and Prometheus exposes qm_refused_origin_checks_total{room,origin}. Anything above zero means a page is being served unqueued right now. Alert on it.
A room with neither a targetUrl nor returnOrigins has named no site at all. It stays open to every site (*), and /api/admin/health lists it, so an open room never looks like a locked-down one.
Configuration reference
Each setting has a data- attribute, and — when qm.js is loaded as its own file via src — a query parameter on that src as a second channel. The attribute wins wherever both are present: the query parameter is read only where the attribute is absent or an empty string. "Wins" is by presence, not by usability — an attribute holding a value the setting cannot use (data-qm-timeout="fast") still suppresses ?timeout=, and the setting falls back to its default, not to the parameter.
| Attribute | src parameter | Required | Default | Description |
|---|---|---|---|---|
data-qm-server | server= | no¹ | the origin of the tag's own src | Origin of your Queue Manager deployment, e.g. https://qm.weekday100.com. Trailing slashes are tolerated. |
data-qm-room | room= | yes | — | Room id to check this page against. |
data-qm-timeout | timeout= | no | 2000 | Milliseconds to wait for the door check before giving up and letting the visitor through. |
data-qm-spa | spa= | no | — | Set to exactly auto (case-insensitive) to re-check automatically on every client-side navigation (history.pushState / replaceState / popstate). Any other value disables it. See Single-page apps. |
¹ Omit it only when qm.js is served from the Queue Manager deployment itself, which is the normal install — the tag then talks to the origin it was loaded from. Two cases where omitting it breaks, differently:
- Served as its own file from your own CDN or a reverse proxy. The tag resolves your CDN's origin as the queue server and every door check 404s or is refused by CORS. It fails open, so the site works and all of the traffic is unqueued.
- Inlined, or bundled into your application JavaScript. The executing element has no
src, so there is no origin to fall back to and no query string —data-qm-serveranddata-qm-roomattributes are then the only configuration channel that exists. Without a server the tag returns silently: no redirect, no console message, nothing to find. Inline installs must set both attributes.
The src channel exists because a tag manager template injects a URL and cannot set attributes on the element it creates — see Google Tag Manager template. It is also the escape hatch when a CMS or CSP sanitiser strips data-* attributes:
<script async src="https://qm.weekday100.com/snippet/qm.js?room=checkout"></script>
Do not reuse server, room, timeout or spa as query parameters on the snippet URL for anything else — in particular, not as a cache-buster. Whether one bites depends on the attribute beside it, and both directions are silent:
- Attribute absent → the parameter is the configuration.
?room=1695as a cache-buster puts the tag on room1695, which almost certainly does not exist, so the door check answerspassand the waiting room is simply off for that page. - Attribute present and non-empty → the parameter is read and then discarded. Adding
?room=saleto override a deployeddata-qm-room="checkout"has no effect and produces no warning.
Any other parameter name is ignored, so ?v=3 is a safe cache-buster — but do not put a #fragment on the snippet URL. The query string is split off at the ? and no further, so the fragment ends up inside the value of the last parameter: qm.js?room=checkout#v3 configures the room as checkout#v3, which is not a legal room id. That one fails loudly — the tag names the bad id in the console and does nothing — but it does nothing all the same. Keep the filename qm.js. It matters in one narrow case: when document.currentScript is unavailable (an innerHTML or tag-manager injection) and the element does not carry both data-qm-server and data-qm-room, the tag's last resort is to find itself among the page's <script> elements by src — either containing /snippet/qm.js anywhere, or ending in /qm.js immediately before the ? or the end of the URL. The first form is the more forgiving of the two: served from its usual path, anything may follow the name, including a #fragment. Away from that path only the second applies, so a renamed copy (/assets/qm.v3.js) or anything but ? after the name (/assets/qm.js#v3) is not found, and the tag does nothing. Put the version in a query parameter instead.
One more discovery hazard, for pages carrying more than one copy of the tag: when document.currentScript is unavailable or the executing element has no data-qm-room, the tag adopts the last element matching script[data-qm-server][data-qm-room]. Its attributes and its src query string then become the configuration, and the ?room= on the element that actually executed is discarded. One tag per page.
Reserved URL parameters (do not use these names for your own query params on protected pages):
| Parameter | Direction | Meaning |
|---|---|---|
qm_return | site → waiting room | Absolute URL of the page the visitor was on; the waiting room sends them back there when promoted. |
qm_token | waiting room → site | The signed pass token. qm.js persists it and immediately removes it from the address bar via history.replaceState. |
Token storage: localStorage key qm_<roomId>, mirrored to a cookie qm_<roomId> (SameSite=Lax, 24 h) for browsers where storage is blocked.
The door-session key (sess) — required for custom integrations
The pass token is single-use at the door. The first /api/check that admits a visitor answers with an extra field:
{"action":"pass","reason":"admitted","session":true,"ttlSec":1800,"sess":"<key>"}
Two distinct fields, easy to conflate: session is a boolean marker ("this pass is backed by a live door session") and is present on every admitted check; sess is the key itself and appears only once, on the first check that spends the pass. Your integration must persist sess when it appears — later checks will not repeat it.
sess is issued exactly once, at the moment the pass is spent. Store it first-party to the protected site (qm.js uses localStorage key qmk_<roomId>, mirrored to a cookie of the same name — deliberately not qms_, which is the queue server's own HttpOnly session cookie) and send it back on every later check, either as &sess=<key> or as the X-QM-Session request header.
This is what stops free-riding. The token travels in ?qm_token=, so it lands in every origin/CDN access log; anyone who reads one line would otherwise be able to enter the victim's live session — and everyone behind the same corporate NAT or CGNAT block looks identical at the network level, so no fingerprint can tell them apart. The sess key never appears in a URL on your site and never reaches your logs, so possession of it is the proof the token cannot be.
A check that presents the token but not the key answers:
{"action":"queue","reason":"session_elsewhere","clearToken":true,"waitingRoomUrl":"/w/checkout"}
Honour clearToken: discard your stored token (and key) before redirecting, or the visitor bounces between your site and the waiting room. A visitor who legitimately lost the key — new device, cleared site data — is put back through the waiting room, where their queue session cookie proves who they are and their session is handed straight back to them.
The built-in inline proxy does this itself: on the response that answers clearToken it expires qm_<room> and qm_dk_<room> and mints a fresh place in line on the same response, so a visitor with no JavaScript is never shown a waiting page for a place they do not hold.
Server-side (vouched) integrations using ?ip=&agent= are issued no sess — they are authenticated and have no browser storage to keep one in. Instead the session they open is bound to the channel: every later check on that ticket must come back through the same authenticated integration (same Public API key), or carry the visitor's queue session cookie. This is the same protection by a different proof — without it, an anonymous replay of the leaked ?qm_token= from the vouched public address (i.e. anyone behind that NAT) would ride the session and bypass maxConcurrent entirely.
Two consequences worth planning for:
- Do not mix channels on one room. If some paths call
/api/checkserver-side and others loadsnippet/qm.jsin the browser, a visitor admitted on one channel is refused (session_elsewhere,clearToken:true) on the other. They recover in one bounce through the waiting room, but they do bounce. Pick one channel per room. - A leaked token is still recoverable. The visitor's queue cookie reclaims a vouched session at
/api/joinexactly as it does a key-bound one.
Single-page apps
By default the snippet checks once, on page load. An SPA that navigates with the History API never reloads, so route changes onto a protected view would go unchecked. Two ways to close that gap:
Option A — automatic (recommended). Add one attribute:
<script async
src="https://qm.weekday100.com/snippet/qm.js"
data-qm-server="https://qm.weekday100.com"
data-qm-room="checkout"
data-qm-spa="auto"></script>
The snippet wraps history.pushState / history.replaceState (calling the originals first, so React Router, Vue Router, Next.js, SvelteKit and friends are unaffected) and listens for popstate. Every client-side navigation triggers a re-check. Re-checks are cheap — one fail-open GET — and a visitor holding a valid pass keeps getting "pass" for the whole passedTtlSec window, so there is no flicker and no repeated queueing.
Option B — manual, from your router. Call window.QueueManager.check() after navigating onto a protected view:
// React Router v6+
const location = useLocation();
useEffect(() => { window.QueueManager?.check(); }, [location.pathname]);
// Vue Router
router.afterEach(() => window.QueueManager?.check());
// Next.js (App Router)
const pathname = usePathname();
useEffect(() => { window.QueueManager?.check(); }, [pathname]);
Manual mode is the right choice when only some routes are protected: guard the call with your own route matching and skip the request entirely on unprotected views.
window.QueueManager also exposes token() (returns the stored token or null), roomId, server, and version.
Protecting API/XHR calls from an SPA. The snippet gates navigation, not fetch() calls your app makes. If a surge hits your JSON API rather than your pages, enforce the check server-side on the API route (see Server-side check) and answer queued callers with a status your app can act on:
// API route variant of the middleware: 401 + queue URL instead of a 302
if (action === 'queue') {
return res.status(401).json({ queued: true,
waitingRoomUrl: new URL(waitingRoomUrl, QM_SERVER).href });
}
Your SPA's fetch wrapper detects queued: true and does location.href = waitingRoomUrl + '?qm_return=' + encodeURIComponent(location.href).
URL targeting (which pages a room protects)
A room's scope is runtime configuration on the room, not something baked into wherever you pasted the check. Type /checkout* into the room's Queued URLs box, save, and every integration point enforces it on its next check — no deploy, no theme edit, no Worker release. This is the thing you change at 2 a.m. without waking someone who can ship code.
Rule syntax, one per line:
| You write | It means |
|---|---|
/checkout | exactly that path (/checkout/ is a different path) |
/checkout* | that path and anything under or after it |
*/cart | any path ending in /cart, on any host |
/checkout?step=2 | a rule containing ? is matched against path and query |
https://shop.example.com/checkout* | pins the origin too, so another host with the same path does not match |
!/checkout/thank-you | an exception: never queue this |
Matching is case-insensitive. Exceptions always win, regardless of the order you list them in — /checkout* above !/checkout/thank-you behaves the same as the reverse, because order-dependent rules are a footgun rather than a feature. Limits: 25 rules, 300 characters each, 10 wildcards each.
Leave the box empty and the room is unscoped: every page carrying the tag is queued, exactly as it behaved before this feature existed. Existing rooms are untouched.
How each integration point learns the scope
snippet/qm.js(≥ 1.2.0) sends the current URL on every check (/api/v1/check?roomId=…&url=…), and the server decides. Nothing about scope lives in the tag, so a room re-scoped in the console takes effect without touching the page.- Server-side and edge connectors either do the same — pass
?url=and let the server answer — or, if they want to skip the network call for pages that are obviously out of scope, pull the rules fromGET /api/v1/targeting?roomId=…and cache them against theETag. - Anything that sends neither
url=nor aRefereris answeredunknownand queued anyway. Failing open here would mean a stale integration silently stops protecting the site; failing into the queue is the safe direction, and the room view names the offender so you can fix it.
An out-of-scope page answers {"action":"pass","reason":"out_of_scope"} and never mints a place in line.
Checking your rules are doing what you think
The room view lists every rule with how many checks it has matched and when it last matched, plus counts of checks that matched nothing and checks that arrived with no URL at all. A rule sitting at never matched during a live sale is the visible symptom of a room pointed at the wrong pages — the cross-room footgun that used to be silent.
There is also a dry run: paste a URL, see which rule would win, before saving.
# what the connectors read
curl -s 'https://qm.weekday100.com/api/v1/targeting?roomId=checkout'
# → {"roomId":"checkout","rev":3,"scoped":true,"updatedAt":…,
# "patterns":[{"pattern":"/checkout*","action":"queue"},
# {"pattern":"/checkout/thank-you","action":"ignore"}]}
# dry run, from the operator side (does not touch the match counters)
curl -s -H "Authorization: Bearer $ADMIN_KEY" \
--get --data-urlencode 'url=https://shop.example.com/checkout/thank-you' \
'https://qm.weekday100.com/api/v1/admin/rooms/checkout/targeting'
# → …"test":{"scope":"out","rule":"/checkout/thank-you","action":"ignore","queued":false}
Fail-open by design
A broken queue must never take down your site. The snippet lets the visitor through — takes no action at all — whenever any of these happen:
/api/checkdoes not answer within the timeout (default 2 s),- the request fails at the network level (DNS, TLS, connection refused, CORS),
- the server answers with a 5xx status,
- the response body is not valid JSON or has no recognizable
action, - the script tag is misconfigured (missing server/room attributes).
A 429 is not on that list. A rate-limited check means the queue server is up and shedding load — usually at peak, exactly when the line matters — so failing open on it would let every visitor skip the line. Every shipped connector (the snippet 1.4.0, the Node package 1.1.0, the WordPress plugin 1.2.0, the Cloudflare Worker 1.2.0) sends the visitor to the room's waiting room instead: /w/<room> on the configured server, the same URL the queue answer names (the rate limiter's {"code":"rate_limited"} body names none), with qm_return set. It is not counted as a fail-open. A sustained qm_rate_limited_checks_total means raise CHECK_LIMIT_PER_MIN (QM-379).
In short: an answer of pass loads the page; an answer of queue, or a 429, sends the visitor to the waiting room; no usable answer within 2 s (a timeout, a network or CORS error, a 5xx, a reply that is not JSON, or a misconfigured tag) lets the page load unqueued.
Except a visitor already through the door (QM-413). The door-session key, sess, is handed out once, by the queue, on the check that spends a pass — so a caller holding one was let in, and a 429 is the queue too busy to answer, not a verdict against them. The snippet 1.5.0 leaves a visitor with a stored qmk_<room> key on the page, and the Node package 1.2.0 passes an unvouched check that presents one (action: "pass", reason: "rate_limited"). Neither is a fail-open, and neither is counted as one.
From snippet 1.5.3 and Node package 1.3.1 (QM-527) the key alone is not enough: it counts only if that page or process saw the queue issue it, within the last 30 minutes (the default session idle TTL). The snippet stores the issue time as qmt_<room>; the Node client remembers the keys it was issued in memory (at most 10,000, oldest dropped first). A made-up key, a stale qmk_<room> from an earlier visit, or a key issued by another app process queues on a 429.
This only holds where the queue issues a key: the direct, unvouched channel. A vouched check (?ip=&agent= with the Public API key) is issued none and the server never reads one on it, so the Cloudflare Worker, the WordPress plugin and the Node middleware in its default vouched mode have no key to show, and a qmk_<room> cookie in front of them is stale or forged. They still send everyone to the waiting room on a 429.
The key survives a detour through another room (QM-453). The snippet 1.5.1 takes a ?qm_token= only if it is for its own room (the token's room field). A pass for a different room, for example after a qm_return from room B that lands on a page tagged for room A, is removed from the address bar but does not replace room A's qm_<room> or clear its qmk_<room>. Before 1.5.1 that visitor lost the room A key and was sent back to the queue on every check until the session expired. The Node middleware and the Cloudflare Worker do the same from 1.2.1 (QM-490): a ?qm_token= for another room is removed from the address bar and the qm_<room> cookie is checked instead, not the foreign token.
A link cannot log a visitor out (QM-481). From snippet 1.5.2 a ?qm_token= for this room is only a candidate. The snippet removes it from the address bar and sends it on the check without the door key. Only when the server answers pass does it replace qm_<room> and drop the old qmk_<room>. If the server refuses it, the stored pass and key stay, and the snippet checks the stored pass instead. Before 1.5.2 any link carrying a made-up token for the room replaced the visitor's pass and deleted their key before the server had seen it.
A connector cannot verify the key — it holds no secret — so this is presence only, and a forged or stale key gets its holder past a 429. What bounds it: it works only while checks are being refused — the key still goes up as X-QM-Session, so the next answered check judges it, and a visitor without a live session is queued there; it never overrides an answer the queue did give; and on the snippet it grants nothing a visitor could not already take by blocking the script. The worst case is a visitor on the site the room never admitted, for as long as the 429s last — which is why a sustained qm_rate_limited_checks_total is worth fixing, not tolerating.
The server behaves the same way: a check against an unknown or deleted room returns {"action":"pass","reason":"no_room"} — but it is no longer silent about it. Each unknown room id is counted, surfaced in GET /api/admin/health as unknownRoomChecks and in the Prometheus exposition as qm_unknown_room_checks_total{room="..."}, and logged once. A non-zero counter means a deployed snippet names a room that does not exist and its waiting room is doing nothing.
The same is true of the credential. A vouched check (?ip=&agent=) from a server-side or edge connector holding the wrong PUBLIC_API_KEY is answered 401, and every shipped connector then fails open — which is correct, and would otherwise be invisible, because the room exists, the origin is fine, and the visitor never reaches the waiting page, so none of the other counters move. It is counted as refusedKeyChecks in GET /api/admin/health, exposed as qm_refused_key_checks_total{room="..."}, and logged once per room with the first four characters of the key that was presented. Non-zero means a deployment somewhere is serving its pages unqueued right now — nearly always a key rotation that missed one place. Alert on it alongside the other three.
The trade-off is deliberate: during a queue-server outage your origin takes unfiltered traffic, which is exactly the traffic it would take if you had no waiting room at all. The alternative — fail-closed — turns every queue hiccup into a full site outage. If a specific URL absolutely must not be reachable without a pass (e.g. a limited drop), enforce it server-side as well (next section) and choose your own failure policy there.
Server-side check (defense in depth)
The client snippet is convenience; the same /api/check contract works from your edge or backend. A visitor cannot forge a pass: tokens are HMAC-SHA256 signed by the queue server.
The contract
GET {server}/api/v1/check?roomId=<id>&token=<token>
200 {"action":"pass"}
200 {"action":"queue","waitingRoomUrl":"/w/<id>"}
/api/v1/* is the pinned public contract; the unversioned /api/* path is a permanent alias that answers identically (see API versioning). Examples below use the short form for readability — prefer /api/v1/ in code you deploy.
Wrong verb on a contract path (e.g. DELETE /api/v1/join) is 405 {"code":"method_not_allowed"} with an Allow header, not a 400.
waitingRoomUrl may be relative — resolve it against the queue server origin. Try it with curl:
# no token → queue
curl -s "https://qm.weekday100.com/api/check?roomId=checkout"
# → {"action":"queue","waitingRoomUrl":"/w/checkout"}
# with a valid passed token → pass
curl -s "https://qm.weekday100.com/api/check?roomId=checkout&token=eyJ..."
# → {"action":"pass"}
Node / Express — install the package
The package is not on npm yet, so install it from the tarball your own queue server builds and serves. Substitute your server's origin for https://qm.weekday100.com and the current connector version for 1.3.0:
npm install https://qm.weekday100.com/connectors/node/queue-manager-node-1.3.0.tgz
Use the versioned URL, not latest.tgz. npm writes the sha512 of the tarball it fetched into package-lock.json against the URL. The bytes behind latest.tgz change on every connector release, so after the server upgrades, npm ci fails with EINTEGRITY. A versioned URL always serves the same bytes, and the server keeps serving every released version, so the lockfile keeps installing. To upgrade, run the registry's upgrade command (the new versioned URL) and commit package.json and package-lock.json. npm update does not move a URL dependency.
GET /api/v1/connectors always carries the command for the server you are actually integrating with, under install, with published: false. The package is not on the npm registry and the @queue-manager scope is not registered there (QM-573): never install it by package name — anyone could register that name. Install only from your server's versioned tarball URL.
const { middleware } = require('@queue-manager/node');
app.use('/checkout', middleware({
server: 'https://qm.weekday100.com',
roomId: 'checkout',
publicApiKey: process.env.QM_PUBLIC_API_KEY, // vouches for the visitor
cookie: { secure: true },
}));
That is the whole integration. The package speaks the pinned /api/v1/check contract, vouches for the visitor's real address, honours clearToken, scrubs qm_token out of the address bar, and fails open on timeout, 5xx, a malformed body or an unknown room — but queues on a 429. It refuses to start without a publicApiKey rather than silently binding every visitor to your app server — see the hand-written version below for why that matters. npx --no queue-manager verify turns a typo'd room id into a failed build instead of a room that queues nobody (keep --no: it runs the binary from your own node_modules and never falls back to the registry, where bare queue-manager is an unrelated package and the scoped @queue-manager/node 404s), and re-running the install command is the upgrade path. See Installable connectors for the registry, the integrity hashes and the WordPress and GTM equivalents.
Middleware by hand (Express-style, complete)
If you would rather own the code, this is the whole contract. Read the two things that are easy to get wrong before you copy it:
- Vouch for the visitor. A server-side check arrives from your address with your HTTP client's
User-Agent. The pass was minted in the visitor's browser and is bound to that client, so an anonymous server-side check presents it as somebody else and the door refuses it — every time, which is an infinite loop between your site and the waiting room, not a one-off bounce. Send the visitor's real address and agent, and authenticate the call withPUBLIC_API_KEY(distributable — it unlocks the vouched door check and nothing else; never sendADMIN_KEYfrom an edge). The npm package throws at startup rather than let you deploy this. The address must be the visitor's real public one: the first vouched check on a ticket its owner waited for in the browser is refused unless it comes from the address (IPv6: the /64) the queue saw, so a leaked?qm_token=cannot be spent through your site. An address of the other family (queue reached over IPv6, your site over IPv4) is refused too: a dual-stack visitor is bounced to the waiting page once, it records that family, and the next check passes. Behind a proxy, set your connector's switch (QM-541). Behind Cloudflare, nginx or a load balancer the connection your app sees comes from the proxy, and by default the Node middleware vouches the socket address and the WordPress plugin vouchesREMOTE_ADDR: the proxy's address, so every visitor who waited is refused withbound_to_other_clientand loops. Use: - Node:
trustProxy: N(the number of proxies in front of the app; the address is read N hops from the right of X-Forwarded-For), orvouchIp: (req) => ...to supply it yourself, e.g.req.headers['cf-connecting-ip']behind Cloudflare. - WordPress:
define( 'QM_TRUSTED_PROXY', true );inwp-config.php(then CF-Connecting-IP, True-Client-IP, X-Real-IP or X-Forwarded-For is trusted), or theqm_client_ipfilter to return the address yourself. - Cloudflare Worker: nothing to set; it vouches
CF-Connecting-IP.
Set them only when every request really reaches the app through that proxy: those headers are client-writable otherwise. Both connectors warn once when they see X-Forwarded-For with neither set.
- A vouched integration is issued no
sess. The channel is the proof: every later check on that ticket must come back through the same authenticated integration. Do not invent a session cookie for it, and do not mix a vouched server-side channel and the browser snippet on one room — see thesesssection.
const QM_SERVER = 'https://qm.weekday100.com';
const ROOM = 'checkout';
const QM_KEY = process.env.QM_PUBLIC_API_KEY; // PUBLIC_API_KEY, not ADMIN_KEY
// Read the cookie off the raw header rather than req.cookies: this example has
// to run with nothing but Express, and req.cookies is undefined unless
// cookie-parser is mounted. Reading it here would throw before the try below,
// which turns a queue integration into a 500 on the page it is protecting —
// fail-CLOSED, the one outcome this whole document is written to avoid.
function readCookie(req, name) {
const raw = req.headers.cookie || '';
const hit = raw.split(';').map((s) => s.trim()).find((s) => s.startsWith(name + '='));
return hit ? decodeURIComponent(hit.slice(name.length + 1)) : '';
}
async function queueGate(req, res, next) {
const here = `https://${req.headers.host}${req.originalUrl}`;
try {
const token = req.query.qm_token || readCookie(req, `qm_${ROOM}`) || '';
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 2000);
const q = new URLSearchParams({
roomId: ROOM,
token,
ip: req.ip, // the VISITOR, not this server
agent: req.get('user-agent') || '',
url: here, // so URL targeting can apply
});
const r = await fetch(`${QM_SERVER}/api/v1/check?${q}`, {
signal: ctrl.signal,
headers: { Authorization: `Bearer ${QM_KEY}` },
});
clearTimeout(timer);
// 429 is the queue UP and shedding load: queue, do not fail open.
if (r.status === 429) {
return res.redirect(new URL(`/w/${ROOM}`, QM_SERVER) + `?qm_return=${encodeURIComponent(here)}`);
}
if (r.status >= 500) return next(); // fail-open: the queue is down
// 401/403 is NOT the queue being down — it is this middleware holding the
// wrong key, and falling through would serve the whole sale unprotected
// with nothing in the logs. Fail open (a broken queue must never be an
// outage) but say so loudly, every time, so a key rotation cannot switch
// protection off in silence.
if (r.status === 401 || r.status === 403) {
console.error(`[queue] ${r.status} from the queue server: QM_PUBLIC_API_KEY is wrong or revoked. `
+ `${ROOM} is being served UNPROTECTED.`);
return next();
}
const body = await r.json();
if (body.action === 'queue') {
if (body.clearToken) res.clearCookie(`qm_${ROOM}`); // token names someone
const back = encodeURIComponent(here); // else's session
return res.redirect(new URL(body.waitingRoomUrl, QM_SERVER) + `?qm_return=${back}`);
}
// action === "pass"
if (req.query.qm_token) { // persist token for later requests
res.cookie(`qm_${ROOM}`, req.query.qm_token,
{ maxAge: 86400_000, sameSite: 'lax', httpOnly: true });
}
return next();
} catch {
return next(); // timeout/network → fail-open
}
}
app.use('/checkout', queueGate);
Server-side you get an upgrade the browser snippet can't have: the token cookie can be httpOnly, and there is no pre-redirect flash — a queued visitor never receives the protected page at all.
Not vouching? If you cannot get the visitor's address (some serverless platforms), use the browser snippet for that room instead. An anonymous server-side check is not a lighter version of this — it is a room where nobody can get through the door.
nginx pseudo-code (njs / Lua-style)
nginx's stock auth_request can't parse a JSON body, so use a scripting layer (njs, OpenResty/Lua) with this shape:
location /checkout {
# subrequest to the queue server with a short timeout
# GET $QM_SERVER/api/check?roomId=checkout&token=$cookie_qm_checkout
# &ip=$remote_addr&agent=$http_user_agent
# Authorization: Bearer $QM_PUBLIC_API_KEY # required: see below
# timeout 2s;
#
# if (status == 429) -> # busy, not down: queue
# return 302 $QM_SERVER + "/w/checkout?qm_return=" + escape(...);
# if (request failed OR status >= 500) -> proxy_pass upstream # fail-open
# if (status == 401 or 403) -> log LOUDLY, then proxy_pass
# (a wrong key is a config error, not a queue outage: fail open, but
# never silently — this location is unprotected until it is fixed)
# if (body.action == "pass") -> proxy_pass upstream
# if (body.action == "queue") ->
# return 302 $QM_SERVER + body.waitingRoomUrl
# + "?qm_return=" + escape($scheme://$host$request_uri);
}
Key points to keep, whatever the platform: short timeout, treat every failure as "pass" (a 429 is not a failure: send it to /w/<room>), forward qm_return so visitors land back on the right page — and vouch. A check made from your infrastructure rather than from the visitor's browser must carry ?ip=&agent= with the PUBLIC_API_KEY, or the queue refuses the very passes it issued.
PHP (framework-free, complete)
Drop this at the very top of any protected .php page, before any output:
The$keyargument is not optional. This runs on your server, not in the visitor's browser, so the queue sees your app server's address rather than theirs. Without vouching (?ip=&agent=, authorised byPUBLIC_API_KEY) every promoted visitor is answeredbound_to_other_clientand bounces back to the waiting room forever. See Sessions.
<?php
function qm_gate(string $server, string $room, string $key): void {
$token = $_GET['qm_token'] ?? $_COOKIE["qm_$room"] ?? '';
$sess = $_COOKIE["qmk_$room"] ?? '';
// Vouch for the visitor, or nobody gets through the door. Use whatever your
// stack trusts for the client address (REMOTE_ADDR behind no proxy).
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$hdr = "Authorization: Bearer $key\r\n"
. ($sess ? "X-QM-Session: $sess\r\n" : '');
$ctx = stream_context_create(['http' => [
'timeout' => 2,
'header' => $hdr,
'ignore_errors' => true,
]]);
$raw = @file_get_contents(
"$server/api/check?roomId=" . rawurlencode($room) .
"&token=" . rawurlencode($token) .
"&ip=" . rawurlencode($ip) . "&agent=" . rawurlencode($agent), false, $ctx);
if ($raw === false) return; // network/timeout -> fail-open
// 429: the queue is up and shedding load -> queue, do not fail open.
if (str_contains($http_response_header[0] ?? '', ' 429 ')) {
$back = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
. "://{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}";
header("Location: $server/w/" . rawurlencode($room) . '?qm_return=' . rawurlencode($back), true, 302);
exit;
}
$body = json_decode($raw, true);
if (!is_array($body) || !isset($body['action'])) return; // malformed -> fail-open
if ($body['action'] === 'queue' && !empty($body['waitingRoomUrl'])) {
if (!empty($body['clearToken'])) {
setcookie("qm_$room", '', time() - 3600, '/');
setcookie("qmk_$room", '', time() - 3600, '/');
}
$back = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
. "://{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}";
$url = (str_starts_with($body['waitingRoomUrl'], 'http')
? $body['waitingRoomUrl'] : $server . $body['waitingRoomUrl'])
. '?qm_return=' . rawurlencode($back);
header("Location: $url", true, 302);
exit;
}
// action "pass" (or anything unexpected): let through
$opts = ['expires' => time() + 86400, 'path' => '/',
'httponly' => true, 'samesite' => 'Lax'];
if (!empty($_GET['qm_token'])) setcookie("qm_$room", $_GET['qm_token'], $opts);
if (!empty($body['sess'])) setcookie("qmk_$room", $body['sess'], $opts);
}
qm_gate('https://qm.weekday100.com', 'checkout', getenv('QM_PUBLIC_API_KEY'));
?>
Cloudflare Worker (edge connector)
This is a shipped, versioned, checksummed artifact — not a snippet to paste. Your own queue server builds and serves it, so the connector you install always matches the contract that server speaks:
curl -fsSL https://qm.weekday100.com/connectors/cloudflare/latest.js -o qm-edge.js
npx wrangler deploy qm-edge.js --name qm-edge --compatibility-date 2025-01-01 \
--var QM_SERVER:https://qm.weekday100.com --var QM_ROOM:checkout \
--var QM_PUBLIC_API_KEY:<PUBLIC_API_KEY> --route 'shop.example.com/*'
Or take the whole wrangler project, with wrangler.toml and a pre-deploy check that will not let a typo'd room id ship:
mkdir qm-edge && curl -fsSL https://qm.weekday100.com/connectors/cloudflare/latest.tgz \
| tar -xz -C qm-edge --strip-components=1
cd qm-edge
$EDITOR wrangler.toml # QM_SERVER, QM_ROOM, routes
npx wrangler secret put QM_PUBLIC_API_KEY
QM_PUBLIC_API_KEY=... npm run deploy # `verify` runs first; exit 1 stops the deploy
Both artifacts carry a SHA-256 published in GET /api/v1/connectors, and the room's Install dialog in the console prints the command with your origin and room id already filled in.
Why this is the connector that matters. The npm middleware and the WordPress plugin run inside your origin; the browser tag and the GTM template run in the visitor's browser, where anyone who disables JavaScript walks straight past them. This one runs in front of your origin: a queued visitor never reaches your servers, and the redirect is written before any HTML exists. It is also the only one an operator can adopt without changing their stack at all — the origin can be .NET, a vendor storefront, or something nobody has the source to.
What it does per request
| Step | Behaviour |
|---|---|
| Scope | Pulls the room's URL rules from GET /api/v1/targeting, caches them in the isolate against the ETag, refreshes in the background. Re-scoping a room mid-incident is a save in the console, not a redeploy. The cache is only ever used to skip a check on a page the rules exclude — a cold or stale cache always defers to the authoritative check, so it can never wave traffic past the queue. |
| Check | GET /api/v1/check with the visitor's qm_<room> cookie, the page URL, and the visitor's real address and User-Agent vouched for. 2 s ceiling. |
| Queue | 302 to the waiting room with qm_return, Cache-Control: no-store, and cookie clears when the server answers clearToken. |
| Pass | Forwarded to origin. A pass that arrived as ?qm_token= is stored as a cookie and scrubbed out of the address bar with a redirect — done at the edge, so it works with JavaScript off. |
| Busy | 429 from the check: the queue is up and shedding load, so it is not a failure — 302 to /w/<room> on QM_SERVER with qm_return, no X-QM-Failover, not counted, logged once per isolate. |
| Failure | Timeout, 5xx, 401, unparseable body, programmer error: forwarded to origin. A queue that is down must never be an outage of the site it protects. Never silently: the response carries X-QM-Failover: timeout | unreachable | http-<status> | invalid-response, and is counted in Analytics Engine when QM_ANALYTICS is bound (alert query in OPERATIONS, The edge gate fails open, and says so). |
| Validation | Once per isolate, off the request path, GET /api/v1/verify — and the failing checks are printed in your Worker log (wrangler tail qm-edge). |
Configuration
| Var | Required | Meaning |
|---|---|---|
QM_SERVER | yes | Origin of your queue server |
QM_ROOM | yes | Room id, exactly as it appears in the console |
QM_PUBLIC_API_KEY | yes | The server's PUBLIC_API_KEY (not ADMIN_KEY) |
QM_GATE | no | navigations (default) · documents · all |
QM_ANALYTICS | no | Analytics Engine dataset binding that counts fail-opens |
QM_GATE=navigations gates GET/HEAD requests the browser has not declared to be a subresource. A client that declares nothing at all — curl, a scraper, a scripted buyer — is gated: for the traffic a drop exists to hold back, the fail-safe direction is towards the queue. documents gates only Sec-Fetch-Dest: document; all gates every request and every method (a POST cannot be redirected without losing its body, so choose it deliberately).
Why the Public API key is not optional
A visitor's place in line is bound to the browser that queued for it. The Worker is not that browser — it calls the queue server from a Cloudflare colo. An anonymous edge check on a promoted visitor's pass therefore answers bound_to_other_client, and the visitor ping-pongs between your site and the waiting room forever. This is the same rule stated under Sessions: an anonymous server-side check is not a lighter version of a vouched one — it is a room where nobody can get through the door.
QM_PUBLIC_API_KEY lets the Worker vouch (?ip=&agent=) with the address Cloudflare reports in CF-Connecting-IP. Without it the Worker refuses to gate anything and says so in the log; an unprotected site is bad, a redirect loop for every customer is worse. The pre-deploy check fails on a missing key and on a key the server rejects, so neither reaches production.
The source
Read it before you run it — it is served inline at https://qm.weekday100.com/connectors/cloudflare/latest.js, about 300 lines of zero-dependency JavaScript, and the same bytes are inside the project tarball at src/worker.js. Its digest is in the registry, so you can pin it in CI:
curl -s https://qm.weekday100.com/api/v1/connectors \
| grep -A2 '"id": "cloudflare"'
Porting to another edge
The same shape ports to CloudFront Functions / Lambda@Edge, Fastly Compute and Akamai EdgeWorkers: read cookies, GET /api/v1/check with a short timeout and the visitor's address vouched for, 302 on queue and on a 429, forward on pass, and otherwise always fail open. Those platforms have no shipped connector yet — see Limitations and roadmap.
Installable connectors
Four connectors are built and served by your own queue server, so the version an operator installs always matches the contract that server speaks — there is no way to be running a connector from a different release than the queue it talks to. They differ in where they run, which is the choice that actually matters:
| Connector | Runs | JS-disabled visitor | Needs origin change |
|---|---|---|---|
| Cloudflare Worker | at the edge, before your origin | held | no |
@queue-manager/node | inside your origin | held | yes |
| WordPress plugin | inside your origin | held | plugin install |
| GTM template / snippet | in the visitor's browser | walks straight through | no |
The registry is public:
curl -s https://qm.weekday100.com/api/v1/connectors
{
"serverVersion": "1.3.0",
"apiVersion": "v1",
"connectors": [
{
"id": "node",
"name": "@queue-manager/node",
"kind": "server-sdk",
"version": "1.3.0",
"platform": "Node.js >= 18",
"install": "npm install https://qm.weekday100.com/connectors/node/queue-manager-node-1.3.0.tgz",
"selfHostedInstall": "npm install https://qm.weekday100.com/connectors/node/queue-manager-node-1.3.0.tgz",
"published": false,
"upgrade": "npm install https://qm.weekday100.com/connectors/node/queue-manager-node-1.3.0.tgz",
"verifyCommand": "npx --yes https://qm.weekday100.com/connectors/node/latest.tgz verify --server https://qm.weekday100.com --room <roomId> --origin https://<your-site> --strict",
"download": "https://qm.weekday100.com/connectors/node/latest.tgz",
"docs": "https://qm.weekday100.com/docs/integration#installable-connectors",
"integrity": "sha256-+0O2S3DhR9eMI2e3kSDgzB9CAFfGJpyuQybypYDd/aY=",
"sha256": "fb43b64b70e1...",
"bytes": 16062,
"notes": "Express/Connect/node:http middleware, install-time verify, drift detection."
}
]
}
Every artifact is built from the same source tree as the server and packed with a fixed timestamp, so the bytes — and therefore integrity — are reproducible and can be pinned in CI. A monitoring job can poll this endpoint to alert on connector drift. The room's Install dialog in the dashboard shows the same list with copy-ready commands and a Run check button that validates the install against the live room before you deploy it.
npm — @queue-manager/node
See Node / Express above. npx --no queue-manager verify --strict in CI, npx --no queue-manager update to detect drift (it exits 1 when the install is behind the server). Without --no npx falls back to the registry, where bare queue-manager is an unrelated package (QM-489) and the scoped @queue-manager/node is not registered (QM-573) — install in the registry document above is always the command that works against the server you are integrating with.
WordPress plugin
Download https://qm.weekday100.com/connectors/wordpress/latest.zip and upload it under Plugins → Add New → Upload Plugin. Then Settings → Queue Manager: enter the server URL and room id, and the settings screen calls /api/v1/verify against your server before it will save — a typo'd room id is a validation error on the settings page, not a silently unprotected site.
The plugin gates on the server side (a template_redirect hook, before any output), so a queued visitor never receives the protected page. It checks the connector registry for updates on the normal WordPress update schedule, so it appears in Plugins → Updates like anything else.
Behind Cloudflare, nginx or a load balancer, add define( 'QM_TRUSTED_PROXY', true ); to wp-config.php or return the visitor's address from the qm_client_ip filter; otherwise the plugin vouches the proxy's address and the queue sends every visitor who waited back to the waiting room. See Middleware by hand, point 1.
Scope it with the plugin's own path rules, or leave it unscoped and use the room's URL targeting so the rules live on the queue server and change with no deploy.
Google Tag Manager template
Download https://qm.weekday100.com/connectors/gtm/template.tpl and import it under Templates → New → Import (the /connectors/gtm/latest.zip bundle has the same template plus its metadata for a gallery submission), or search the Community Template Gallery for "Queue Manager" once it is published there. Then add a tag from the template: Server URL and Room ID are validated fields, not free text in a Custom HTML tag.
- Trigger: Initialization — All Pages if your container supports it, otherwise Page View. The earlier it fires, the smaller the flash.
- Before you publish, run Preview. The tag asks this server whether the room exists and whether it accepts the site you are previewing, and writes the verdict to the GTM debug console: either
room "checkout" checked outor aREFUSED room "checkout"line naming what to fix. A published container fires on real traffic with nothing to catch a typo, so this is the one moment that can. The check runs only in Preview/debug — visitor pageviews never make it. - Caveat, stated honestly: GTM loads after the page's own
<head>scripts, so the pre-redirect flash window is wider than with a direct tag. For a hard-gated drop, prefer the direct tag or a server/edge check.
<details> <summary>Custom HTML tag (if you cannot use the template)</summary>
<script>
(function () {
var s = document.createElement('script');
s.async = true;
s.src = 'https://qm.weekday100.com/snippet/qm.js';
s.setAttribute('data-qm-server', 'https://qm.weekday100.com');
s.setAttribute('data-qm-room', 'checkout');
document.head.appendChild(s);
})();
</script>
Nothing validates the room id here. Run the Run check button in the room's Install dialog, or curl "https://qm.weekday100.com/api/v1/verify?roomId=checkout", before you publish the container. </details>
Shopify
No connector. Online Store → Themes → Edit code → theme.liquid, paste the Quickstart tag immediately after <head>. For checkout itself Shopify Plus users can add it via checkout.liquid / Checkout Extensibility; standard plans can protect the cart and product pages, which is where the surge lands first.
WordPress without the plugin
If you would rather not install anything, the script tag still works — add to your child theme's functions.php:
add_action('wp_head', function () {
echo '<script async src="https://qm.weekday100.com/snippet/qm.js"
data-qm-server="https://qm.weekday100.com"
data-qm-room="checkout"></script>';
}, 0); // priority 0: as early in <head> as wp_head allows
This is the client-side gate, so it has the pre-redirect flash the plugin does not, and nothing validates the room id for you.
API versioning (/api/v1/*)
The public wire contract is served twice, from the same handlers:
| Form | Use it for |
|---|---|
/api/v1/<route> | canonical. Pinned to the shapes documented here. |
/api/<route> | permanent alias, kept for already-deployed integrations. |
/api/v1/check, /api/v1/join, /api/v1/status, /api/v1/events and the admin routes under /api/v1/admin/* are byte-for-byte the unversioned ones — same status codes, same JSON, same code values, same headers. Only the path differs.
Why it exists: your copy of qm.js lives on your origin, not the queue server's, so the two cannot be redeployed in the same breath. Pinning the version in the path means a future contract change ships as /api/v2/* and your deployed integration keeps getting exactly the responses it was written against, instead of silently receiving new ones.
Policy:
- New integrations use
/api/v1/*. The snippet already does; it asks for/api/v1/checkand only falls back to/api/check(once, for the life of the page) if the server answers404, which is what a pre-v1 queue server does. - The unversioned paths are not deprecated and will not be removed. They stay pointed at the newest contract. Today that is v1, so they are identical; the day a v2 exists they follow it, which is precisely why a pinned integration should not use them.
- Breaking changes get a new prefix, never a new shape under an old one. Adding an optional response field is not breaking and lands in v1.
- Health (
/healthz), the waiting-room page (/w/:id) and the static snippet are not part of the versioned surface.
# identical answers, one of them pinned
curl -s "https://qm.weekday100.com/api/v1/check?roomId=checkout"
curl -s "https://qm.weekday100.com/api/check?roomId=checkout"
Error codes
Every error answers with the same shape — {"error": "…", "code": "…"} — and code is the stable part of it. Branch on code, never on the human-readable message. These are the ones a customer-site integration can actually receive:
code | Status | When |
|---|---|---|
room_not_found | 404 | No such room on this server: a typo in data-qm-room, or a room that was deleted. |
invalid_token | 401 | The token did not verify, or none was supplied where one is required. Discard it and rejoin. |
stale_ticket | 409 | The ticket belongs to an earlier generation of that queue (the room was reset). Discard and rejoin. |
rate_limited | 429 | Over the per-address budget: JOIN_LIMIT_PER_MIN for POST /api/join, NOTIFY_LIMIT_PER_MIN for POST /api/notify, CHECK_LIMIT_PER_MIN for /api/check, /api/status, /api/targeting, /api/verify, /api/verify.gif and /api/connectors. Honour Retry-After. |
invalid_room_id | 400 | roomId missing or not a non-empty string (POST /api/join body, GET /api/verify query). |
invalid_token_field | 400 | token in a POST /api/join or POST /api/notify body is present but not a string. |
blocked_by_protection | 403 | POST /api/join only: the room's bot protection is in enforce mode and scored this request as automated, so it gets no place in line. X-QM-Protection carries the score. Not a rate limit; retrying does not help. |
queue_identity_limit | 429 | POST /api/join: this address already holds the room's queueMaxPerIp places in the live line (default 16) and presented none of them. Nothing spendable comes back. Retry-After: 30. |
prequeue_identity_limit | 429 | POST /api/join: this identity already holds preQueueMaxPerIp entries in a scheduled drop's pre-queue. Retry-After: 30. |
prequeue_full | 503 | That pre-queue is at capacity. Retry. |
storage_unavailable | 503 | The server could not durably record the join, so it refused to hand out a ticket it might forget. Retry. |
sse_capacity | 503 | /events (or /api/events): the server already holds SSE_MAX_TOTAL streams. Retry-After: 5; poll /api/status meanwhile. |
sse_per_ip_limit | 429 | /events: this address already holds SSE_PER_IP open streams. Retry-After: 5. |
unauthorized | 401 | A key was required and was missing or wrong — e.g. /api/check?ip=&agent= without PUBLIC_API_KEY. |
credential_in_query | 401 | /api/check?ip=&agent= with the key sent as ?key= and no Authorization header. The key is only accepted as Authorization: Bearer <key>. |
server_saturated | 503 | The process is at its connection ceiling and shed a non-document request (a page load gets the offline page instead). Retry-After: 5. |
uri_too_long | 414 | Request URL over 4096 bytes. |
bad_request, request_aborted | 400 | The URL could not be parsed, or the request body was cut off mid-read. |
not_inline_host | 404 | A /__qm/… path on a host that fronts no inline room. |
internal | 500 | Unexpected server failure; the message is always internal error. Retry. |
email_notify_disabled | 404 | POST /api/notify on a server where the operator has not set EMAIL_NOTIFY. The feature is off by default; the waiting page hides its control, so a caller only meets this by calling the route directly. |
invalid_email | 400 | The address sent to POST /api/notify is not one. |
invalid_json, invalid_body | 400 | Malformed request body. |
payload_too_large | 413 | Body over the limit. |
method_not_allowed | 405 | Wrong verb on a contract path; the Allow header names the right one. |
not_found | 404 | No such path on this server. |
/api/check is the exception that matters, and it is deliberate: it sits in front of every page load, so an unknown room, an unverifiable token or an unreachable server all resolve to letting the visitor through rather than to an error you have to handle. It returns 200 {"action":"pass"}, and the server counts the unknown-room case so an operator can see the misconfiguration — see Fail-open by design.
Versioning, self-hosting, and SRI
/snippet/qm.js is served by your own Queue Manager deployment — there is no third-party CDN in the path, so you control when it changes. The snippet carries its version in the header comment and exposes it at runtime as window.QueueManager.version. It follows semver; the data- attribute contract and fail-open behaviour are stable across minor versions.
Pinning options, strictest first:
- Copy
qm.jsinto your own asset pipeline. It has zero dependencies and no build step; serving it first-party removes the cross-origin fetch entirely and lets your normal cache-busting handle upgrades. - Subresource integrity. Your server publishes the digest of the bytes it is serving, alongside every other connector's, so you do not compute it by hand and do not have to remember to recompute it on an upgrade:
curl -s https://qm.weekday100.com/api/v1/connectors \ | node -e 'JSON.parse(require("fs").readFileSync(0)).connectors .filter(c => c.id === "snippet").forEach(c => console.log(c.installPinned))'
which prints the tag with the digest already in it:
<script async src="https://qm.weekday100.com/snippet/qm.js"
integrity="sha256-<hash>"
crossorigin="anonymous"
data-qm-server="https://qm.weekday100.com"
data-qm-room="checkout"></script>
To check that answer against the file rather than trust it, hash the download yourself — it must match the integrity field above:
curl -s https://qm.weekday100.com/snippet/qm.js | \
openssl dgst -sha256 -binary | openssl base64 -A
With SRI, a modified qm.js refuses to run — and because the snippet fails open, a refused snippet means visitors pass, never a locked-out site. It also means a pin you forgot to refresh after an upgrade costs you the queue, silently, so re-read integrity when you deploy.
- Do nothing. The file only changes when you upgrade your own deployment.
Limitations and roadmap
Where this product is behind the incumbents (CrowdHandler, Queue-it), stated plainly, so you can decide with the real trade-offs in front of you rather than discovering them during a drop.
1. One edge connector, not four
Shipped — see Installable connectors: the Cloudflare Worker (edge), @queue-manager/node (npm), the WordPress plugin, and a Google Tag Manager custom template with validated Server URL and Room ID fields. All four are built and served by your own queue server, so the version an operator installs always matches the contract that server speaks, and all four ask the server whether the room exists before the install reaches real traffic — the typo'd-room footgun is closed on every channel we ship.
The moment differs by channel, because the channels do. The npm CLI, the WordPress settings screen and the Worker's deploy script each read /api/v1/verify and refuse to proceed on a typo. A tag manager has no install step to refuse — a published tag simply starts firing — so the GTM template does its check in Preview, the step an operator takes before publishing, and says in the debug console whether the room was accepted for that site. It is not run on visitor pageviews. Its sandbox cannot read a response body, so it asks /api/v1/verify.gif, which returns a pixel for a room this server can serve and fails to load for anything else; that is a verdict a tag can see. Preview is a warning, not a gate: publishing a container with a bad room is still possible, it is just no longer silent.
What is still missing next to the incumbents:
- Cloudflare only, at the edge. Queue-it ships Cloudflare, Akamai, AWS CloudFront and Google Service Extensions connectors; CrowdHandler ships Cloudflare, CloudFront, Akamai and a zero-code DNS mode. We ship Cloudflare. An Akamai- or CloudFront-fronted retailer can port the same ~300 lines (the shape is in Porting to another edge and the source is one file), but that is their work, not a product they install. This is the largest remaining integration gap.
- A DNS mode you host, not one we host. Inline mode puts the queue in front of a site with no code on it: the site's DNS points at your queue deployment (through a TLS-terminating CDN or proxy — this server speaks plain HTTP), and the queue forwards admitted visitors to the room's
proxyOrigin(see Operations guide → Two deployments). What CrowdHandler has and we do not is a hosted CNAME target: the thing your DNS points at is a server you run, and while it is down the site is down unless you have set up the CDN failover page. - Not in the marketplaces. Publishing to npm, the WordPress plugin directory and the GTM Community Template Gallery is a release step somebody has to perform with those accounts; until then you install from your own server's
/connectors/...URLs (the commands and the sha256 of every artifact are in the room's Install dialog).npm updatedoes not move a URL dependency: upgrade by re-running the versioned install command. - Cloudflare, Node, WordPress and GTM only. Magento/Adobe Commerce, Shopify, Salesforce Commerce Cloud, and PHP/.NET/Java/Python SDKs do not exist. The hand-written connectors in this document (PHP, nginx sketch) still cover those cases and are still yours to maintain.
- No platform hooks we didn't write. Shopify checkout on non-Plus plans and anything else that will not accept a script tag or a middleware layer is out of reach.
The flip side, which is real: the pinned /api/v1 contract means a connector you never upgrade keeps working rather than quietly breaking (see API versioning), and what you install is code you can read in full, with no vendor runtime executing on your server and no third-party origin in your critical path.
2. Targeting is central, but the rule language is deliberately small
Central URL targeting shipped — see URL targeting. Scope is stored on the room, enforced by the server on every check, published to connectors from /api/v1/targeting, and shown in the room view with per-rule match counts. What remains is narrower, and worth stating plainly:
- Globs, not regexes. You get
*and a leading!. Queue-it and CrowdHandler both accept regular expressions. This is a deliberate trade: operator-authored regexes run on every door check, and a backtracking pattern pasted at 2 a.m. is a self-inflicted outage. If you need "/product/\d{6}but not/product/\d{6}/reviews", you express it as two glob rules or you do it in your connector. - No targeting on anything but the URL. No country, device, cookie, header or percentage-of-traffic conditions. Queue-it triggers can key on those.
- Rules are per room, not shared. Fifteen rooms that all exclude
/healthlist that rule fifteen times; there is no rule library.
3. Other honest gaps
- One queue lives in one process, in one region. You can run several
node server.jsinstances (each with its ownDATA_DIRand a sharedSECRET— see Operations guide → Refusals), but they share nothing else: each has its own line, rate and event log, and a ticket's place in line exists only on the instance that issued it. More instances means more independent queues, not one queue with redundancy. The incumbents run global, multi-region SaaS with their own CDN in front. Your queue's latency and blast radius are whatever the one instance serving that room has. Fail-open (snippet) and the CDN failover page (inline) are the mitigation, not a substitute for redundancy. This is the largest remaining gap and it is architectural, not a missing feature. - Operator keys and roles, but no SSO. Named keys with
owner/operator/viewerroles, instant revocation and an append-only audit trail shipped — see Operations guide → Operators, roles and the audit trail. There is no SAML/OIDC single sign-on, no SCIM provisioning and no MFA, so an enterprise that requires identity-provider-managed access still cannot tick that box./api/admin/healthreportsaccess.sso: falserather than leaving you to find out. - Reporting is historical, not scheduled. Hourly and daily rows per room are retained for
HISTORY_RETAIN_DAYS(default 90) and exported as JSON or CSV from/api/admin/reports. What does not exist: emailed or scheduled reports, a push into a warehouse, and any alerting on a historical trend. Wait percentiles in those rows are reservoir estimates, labelled as such in every response. - Bot scoring, not a bot-management product. Traffic is scored from what the server can observe — declared automation clients, missing browser headers, forged forwarded addresses, machine-regular timing — and
enforcerefuses a place in line above the threshold. It never refuses entry to your site. What it is not: no device fingerprinting, no JS challenge, no CAPTCHA, no proxy/VPN reputation feed, and no invite-only rooms or 2FA on the waiting room. It is calibrated so that no combination of signals a shared address (corporate NAT, CGNAT) can produce on its own reaches the block threshold, which necessarily means a botnet spread thinly across residential addresses and driving real browsers is not caught by it. Fairness against that is still structural: FIFO ticketing, HMAC-signed passes, single-use door sessions, one pre-queue entry per identity.
FAQ
Does the snippet slow my page down? No. It loads async (never blocks parsing or rendering) and the check runs in the background. A queued visitor may see the page for a fraction of a second before redirecting; put the tag first in <head> (before your other scripts) to minimize that window. If you need a hard gate with zero flash, use the server-side check or the Cloudflare Worker edge connector — with those, a queued visitor never receives the protected page at all.
What do visitors see while waiting? The hosted waiting room at /w/<roomId>: live position, honest ETA, and automatic redirect when promoted. Branding is configurable per room via the admin API/dashboard.
Can I tell people what is going on mid-incident? Yes — set branding.message on the room. It appears on the waiting page in its own timestamped pane ("Message from the team — Updated 10:40 PM"), in every state including a scheduled drop's countdown, and it is pushed over the existing SSE stream to everyone already waiting within one heartbeat. Nobody reloads. Clearing the field withdraws it the same way.
Will a visitor lose their place if they close the tab? No. The token in localStorage/cookie keeps their ticket; rejoining with the same token keeps the same position. A visitor who clears storage (or switches browsers) starts at the back of the line.
Can someone skip the queue by deleting the script tag in DevTools? With the client-only integration, a technical user can browse the page HTML — same as every JavaScript-tag waiting room product. What they cannot do is forge a pass token (HMAC-signed server-side). For drops where that matters, add the server-side check; then the page itself is unreachable without a valid pass.
How long is a pass valid? passedTtlSec per room, default 600 s. Within that window the visitor browses protected pages freely; after it expires the next check queues them again.
One room or many? One room protects any number of pages that share the same tag. Use separate rooms (separate tags) when different areas need independent rates — e.g. checkout at 120/min and ticket-drop at 20/min. Passes are room-scoped: a pass for one room never unlocks another — which is a footgun the moment a room's waiting room sends visitors to a page tagged for a different room. That mis-pairing produces no error at all, just a visitor queued twice; see Point each room's waiting room at its own protected pages.
Does it work with tag managers (GTM etc.)? Yes — a ready-made Custom HTML tag is in Google Tag Manager above. The snippet handles document.currentScript being unavailable. Note tag managers usually load later than a direct tag, widening the pre-redirect flash.
What about caching/CDN in front of my site? The check runs in the visitor's browser, so cached HTML is fine — every visitor still gets checked. Just don't cache the page with a qm_token query string; the snippet strips it client-side, but configure your CDN to ignore qm_token/qm_return in cache keys to be safe.
Which browsers are supported? All evergreen browsers (Chrome, Edge, Firefox, Safari ≥ 12). The snippet uses fetch + AbortController with an XHR fallback, and degrades to a plain cookie when localStorage is blocked (e.g. some private-browsing modes). No support for Internet Explorer.
Is any personal data collected? No third-party cookies, no cross-site tracking, no advertising identifiers, nothing sold or shared with anyone. The pass token itself carries only the room id, the ticket number and an issue timestamp, signed.
The queue server does process two things about the visitor that your privacy notice has to account for: their IP address and their User-Agent. It keeps a truncated one-way digest of them — and, in its event log, the IP itself — for exactly one purpose: proving who owns a ticket. The pass token travels in a URL and therefore lands in your access logs, so without that binding anyone who reads one log line could take the visitor's place in the queue. It is first-party to the queue server, is never used to build a profile, and lives in DATA_DIR for as long as you retain that data.
Under GDPR an IP address is personal data, so name this in your notice. The mechanism, and the reason each layer exists, is in Operations guide → How a pass is bound to a visitor.