Queen’s broker speaks plain HTTP/1.1 with JSON bodies. There is no binary protocol, no persistent session, and no client-side coordination state. Anything that can issue an HTTP request is a first-class client. The rules on this page hold for every route; the per-route pages only document what is specific to that route.
Base URL and versioning
The broker binds PORT, which defaults to 6632, on QUEEN_BIND_ADDR, which defaults to
0.0.0.0. A local broker is therefore at http://localhost:6632.
| Prefix | What lives there |
|---|---|
/api/v1/… |
the whole public API: message plane, queues, consumer groups, messages, DLQ, traces, status, analytics, system |
/streams/v1/… |
the stream-processing surface: three JSON POSTs, documented in Streams routes |
/internal/api/… |
broker-to-broker calls; not a client API |
/health, /metrics, /metrics/prometheus, /status |
operational endpoints |
| everything else | the embedded dashboard SPA, served from assets compiled into the binary |
There is exactly one API version, v1. Since the 1.0.0 line, broker and SDK minor versions are
released in alignment. A path is the API version: there is no Accept header negotiation and no
version query parameter.
Anything the router does not match falls through to the dashboard. That fallback answers a
JSON 404 ({"error":"Not Found","code":"no_such_route"}) for any path starting with
/api/ and for any non-GET/HEAD request, so a call to a route that does not exist fails
loudly instead of returning the SPA’s HTML with a 200.
Authentication
JWT bearer authentication is off by default (JWT_ENABLED=false); with it off, every
route is reachable unauthenticated and the broker relies entirely on network isolation. With
it on, send the token in the standard header:
Authorization: Bearer <jwt>A bare token with no Bearer prefix is also accepted. Supported algorithms are HS256/384/512,
RS256/384/512 and EdDSA, selected by JWT_ALGORITHM (or auto); the verification key comes
from a static PEM or from a JWKS endpoint cached per kid. Role claim names are remappable.
The configuration page lists every JWT_* variable, and
Self-hosting covers key distribution.
Two failure codes, and they mean different things:
| Code | Meaning |
|---|---|
401 |
no token, unparseable token, bad signature, expired, not yet valid, or a rejected issuer/audience. Body {"error":"Authentication required"} or a specific token message |
403 |
the token is valid but its roles do not satisfy the route’s access level. Body {"error":"Insufficient permissions"} |
Access levels are a role set, not a ladder
Every route is mapped to one of five access levels. The mapping from a token’s roles to the
levels it satisfies is not hierarchical: write-only is not a weaker read-write, it is a
different capability. This matters most for producers.
| Route’s access level | Satisfied by role | Not satisfied by |
|---|---|---|
public |
anyone, with or without a token | nothing |
read-only |
read-only, read-write, admin |
write-only |
write-only |
write-only, read-write, admin |
read-only |
read-write |
read-write, admin |
read-only, write-only |
admin |
admin |
everything else |
Three routes are write-only, and all three are pure produce operations: POST /api/v1/push,
POST /api/v1/timers (scheduling a timer produces the message it becomes) and
POST /api/v1/ephemeral/push. So a token whose single role is write-only can produce and can
do nothing else: every read route and every consume route (/pop, /ack, /transaction,
/lease/:leaseId/extend) is read-write and returns 403 for it. Conversely a read-only token
cannot pop, because popping takes a lease and mutates a cursor.
Role names come from the role claim and the roles array claim, and both the claim names and
the four role names are configurable. A path the access table does not recognise defaults to
read-write.
The public routes, reachable with no token even when authentication is on, are /health,
/metrics, /metrics/prometheus, /, /assets/*, /favicon*, and the three
dashboard-identity routes /auth/me, /auth/login and /auth/logout. The last three are
public so they can answer the auth question themselves: with JWT on, /auth/me returns 401
from its own handler and /auth/login serves the page that says the dashboard needs the proxy.
/metrics/prometheus being public is deliberate for scraping and is a hazard in a multi-tenant
deployment, where per-queue series sum across tenants; see Self-hosting.
A second, independent exemption list is JWT_SKIP_PATHS, which defaults to
/health,/metrics/prometheus,/metrics,/. Entries match a path exactly, or as a prefix when the
entry ends in /. A path on that list skips authentication entirely, so widening it widens the
unauthenticated surface.
Requests
Bodies are JSON. The broker reads the raw bytes and parses them as JSON without inspecting
Content-Type, so sending application/json is good manners rather than a requirement. A
body that does not parse returns 400 with the parser’s message:
{"error":"bad body: expected value at line 1 column 1"}Query parameters on the message-plane routes are typed, not free-form strings: batch=abc is a
deserialization failure for the whole query string rather than a silently-ignored parameter, and
it is rejected by the extractor before the handler runs. Management routes that take filter
parameters read them as strings and additionally accept 1/0 and yes/no for booleans.
The request body cap is 64 MiB (QUEEN_MAX_BODY_BYTES, default 67108864). A larger body
is rejected by the body-limit layer before any handler runs. The cap applies per request, so a
push of many small messages is bounded by the sum of their payloads plus JSON framing.
Responses
Successful API responses carry Content-Type: application/json. The exception is
/metrics/prometheus, which returns text/plain; version=0.0.4; charset=utf-8 with
Cache-Control: no-cache.
Every 204 has no body at all
When a pop has nothing to deliver, the broker returns 204 No Content with no body and no
Content-Length. This is deliberate, and it is not cosmetic: announcing a content length for
a body that hyper then elides makes strict HTTP/1.1 clients (Node’s undici/llhttp in
particular) treat the connection as poisoned and close it, which under empty-poll load
snowballs into ECONNRESET storms. The bodies that were dropped carried nothing a client read.
Consequences you have to design around:
- Check the status code before parsing. A 204 has no JSON to parse.
- Any diagnostic the handler wanted to attach to an empty result is lost. Pop maintenance mode
is the clearest case: the handler builds
{"messages":[],"paused":true}, and the client sees a bare 204 indistinguishable from an empty queue. - An error reported inside a pop stored procedure’s result also renders as an empty pop, and therefore as a 204 with no text. Genuine transport-level failures (no pool connection, a statement timeout) come back as 500 with a body.
Errors
Every error the broker produces itself is a flat object with one key:
{"error":"invalid or expired lease"}The message is JSON-escaped, so it is always parseable. There is no error code enum, no
details object and no retryAfter; the HTTP status plus this string is the whole contract.
Some routes return a stored procedure’s JSON verbatim
Queen’s management surface is implemented in PostgreSQL. Many routes (/api/v1/configure and
most of the resources, status, analytics, consumer-groups, messages, dlq and traces
handlers) pass the stored procedure’s JSON straight through without reshaping it, because
clients assert on those exact keys. Two things follow:
- The shape of those responses is the stored procedure’s shape, including keys that only make sense internally.
- When such a result carries an
errorkey, the broker maps it to a status code by inspecting the text: 404 if the message containsnot found(case-insensitive), otherwise 500. The original body is preserved.
The message-plane routes on the following pages are the opposite: their bodies are assembled by hand in the broker, field by field, and the fields that are absent are as load-bearing as the ones that are present.
A 2xx does not mean every item succeeded
/api/v1/push, /api/v1/ack and /api/v1/ack/batch answer with a top-level JSON array,
one element per request item, in request order. The HTTP status describes the call; each
element describes one item.
| Route | Status on a processed call | Where the real outcome is |
|---|---|---|
POST /api/v1/push |
201 |
each element’s status: queued, duplicate, buffered or failed |
POST /api/v1/ack, /ack/batch |
200 |
each element’s success, error, noop, dlq, leaseReleased |
POST /api/v1/transaction |
200 |
top-level success; a failure is success:false with error and an empty results |
A client that only checks res.ok will silently lose messages: a push whose database
transaction failed still returns 201, with the affected items marked buffered (spooled to
disk for replay) or failed (not stored anywhere).
Status codes used by the message plane
| Code | When |
|---|---|
200 |
a pop that delivered messages; every ack; a lease extension; a transaction that was processed, including one that rolled back |
201 |
every push the broker processed, including one whose items were spooled to disk |
204 |
a pop with nothing to deliver, pop maintenance, or a pop whose stored procedure reported an error. No body |
400 |
unparseable body, a missing required field, an unknown transaction operation type, a discovery pop with neither namespace nor task, or a malformed x-queen-tenant when tenancy is on |
401 / 403 |
authentication and authorization, as above |
404 |
a path the router does not match, or a stored-procedure error whose text contains not found |
413 |
the request body exceeds the cap |
500 |
no PostgreSQL connection available ({"error":"pool"}), a pop that failed at the transport level, a failed lease renewal, or a stored-procedure error that is not a not-found |
503 |
/health only, when the database round-trip fails |
On the message plane the broker never returns 429: it has no per-client rate limiter there. Its internal admission arbiter is adaptive and makes a request wait for a slot rather than rejecting it, and rate limiting on those routes comes from the multi-tenant gateway rather than from the broker.
The kv, timer and
ephemeral routes are the exception, and they are the exception on
purpose. They are the surfaces whose call rate is decided by an end user of the customer rather than
by a volume of messages, so a defence that lived only in the gateway would not be a defence of the
product: self-hosted and dedicated deployments without a gateway are real. Those routes carry a
per-tenant token bucket in the broker itself and answer 429 with Retry-After. They also answer
403 and 503 where the message plane has no equivalent, and
their pages carry the one sentence that separates the three.
Retrying safely
| Operation | Safe to retry blindly | Why |
|---|---|---|
POST /api/v1/push |
yes, if you send your own transactionId |
deduplication is exact and window-bounded, so the retry returns status:"duplicate" and writes nothing |
GET /api/v1/pop |
no | a pop takes a lease and may deliver a different batch; retrying is a new claim, not a repeat |
POST /api/v1/ack |
yes | an ack that lands below the cursor is reported noop:true rather than double-committing |
POST /api/v1/lease/:leaseId/extend |
yes | renewal never shortens a lease |
POST /api/v1/transaction |
only with deterministic transactionIds |
the whole bundle re-runs; dedup is what makes the pushes idempotent |
POST /api/v1/timers |
yes | schedule and reschedule are the same upsert on (queue, timerKey), so a repeat replaces rather than duplicating |
DELETE /api/v1/timers/:queue/*timerKey |
yes | the second cancel answers absent, which is ok:false and not an error |
The KV operations differ from each other, which is the part that catches people out.
| Operation | Safe to retry blindly | Why |
|---|---|---|
putIfAbsent |
yes | the second attempt loses the race against its own first attempt and reports applied:false, reason:"exists" |
delete |
yes | a delete that matched nothing is applied:false, not an error |
put |
only with expect |
without a precondition it is an unconditional upsert, so a retry overwrites a value another writer put there in between |
get, getMany, getPrefix |
yes | reads |
incr |
no | it is the one operation with no precondition, by design, and a blind retry counts twice |
The exception under incr is worth stating exactly, because it is the case that makes a counter
wrong rather than an operation fail: a retry counts twice unless the transaction that carried it
failed. Inside POST /api/v1/transaction a rolled-back bundle wrote nothing, so re-sending it is
safe; standalone on POST /api/v1/kv, a timeout leaves you unable to tell whether the increment
committed, and there is no expect to make the second attempt conditional. Either put the incr
in a transaction whose failure you can observe, or accept that a retry may double it.
The tenant-scoped column
The generated route table has a Tenant-scoped column. It records whether that handler resolves a tenant identity for the request and threads it into the SQL that resolves queues by name. Native tenant scoping is off by default; with it off, every request resolves to a single default tenant and the column tells you which handlers would participate if it were on. It is an operator-level feature of multi-tenant deployments, not a client-facing one. Self-hosting and Internals cover it, including which routes are not scoped and what that implies.
Every route
Queen’s broker registers 93 method + path pairs. Every row below is read out of the router and the authorization table at build time.
Message plane
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
POST |
/api/v1/ack |
read-write | yes |
POST |
/api/v1/ack/batch |
read-write | yes |
POST |
/api/v1/lease/:leaseId/extend |
read-write | yes |
GET |
/api/v1/pop |
read-write | yes |
GET |
/api/v1/pop/queue/:queue |
read-write | yes |
GET |
/api/v1/pop/queue/:queue/partition/:partition |
read-write | yes |
POST |
/api/v1/push |
write-only | yes |
POST |
/api/v1/transaction |
read-write | yes |
Queues and partitions
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
POST |
/api/v1/configure |
read-write | yes |
GET |
/api/v1/resources/namespaces |
read-only | yes |
GET |
/api/v1/resources/overview |
read-only | yes |
GET |
/api/v1/resources/queues |
read-only | yes |
DELETE |
/api/v1/resources/queues/:queue |
admin | yes |
GET |
/api/v1/resources/queues/:queue |
read-only | yes |
GET |
/api/v1/resources/queues/:queue/depth |
read-only | yes |
GET |
/api/v1/resources/tasks |
read-only | yes |
Consumer groups
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
GET |
/api/v1/consumer-groups |
read-only | yes |
DELETE |
/api/v1/consumer-groups/:group |
admin | yes |
GET |
/api/v1/consumer-groups/:group |
read-only | yes |
DELETE |
/api/v1/consumer-groups/:group/queues/:queue |
admin | yes |
POST |
/api/v1/consumer-groups/:group/queues/:queue/partitions/:partition/seek |
read-write | yes |
POST |
/api/v1/consumer-groups/:group/queues/:queue/seek |
read-write | yes |
POST |
/api/v1/consumer-groups/:group/subscription |
read-write | yes |
GET |
/api/v1/consumer-groups/lagging |
read-only | yes |
Messages, DLQ and traces
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
DELETE |
/api/v1/dlq |
admin | yes |
GET |
/api/v1/dlq |
read-only | yes |
POST |
/api/v1/dlq/:id/replay |
read-write | yes |
GET |
/api/v1/messages |
read-only | yes |
DELETE |
/api/v1/messages/:partitionId/:transactionId |
read-write | yes |
GET |
/api/v1/messages/:partitionId/:transactionId |
read-only | yes |
POST |
/api/v1/messages/:partitionId/:transactionId/retry |
read-write | yes |
POST |
/api/v1/traces |
read-write | yes |
GET |
/api/v1/traces/:partitionId/:transactionId |
read-only | yes |
GET |
/api/v1/traces/by-name/:traceName |
read-only | yes |
GET |
/api/v1/traces/names |
read-only | yes |
Status, metrics and analytics
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
GET |
/api/v1/analytics/dlq-signatures |
read-only | yes |
GET |
/api/v1/analytics/partition-liveness |
read-only | yes |
GET |
/api/v1/analytics/postgres-stats |
read-only | no |
GET |
/api/v1/analytics/queue-lag |
read-only | yes |
GET |
/api/v1/analytics/queue-ops |
read-only | yes |
GET |
/api/v1/analytics/queue-parked-replicas |
read-only | yes |
GET |
/api/v1/analytics/retention |
read-only | yes |
GET |
/api/v1/analytics/system-metrics |
read-only | no |
GET |
/api/v1/analytics/worker-metrics |
read-only | no |
GET |
/api/v1/analytics/workload |
read-only | yes |
POST |
/api/v1/stats/refresh |
admin | no |
GET |
/api/v1/status |
read-only | no |
GET |
/api/v1/status/analytics |
read-only | yes |
GET |
/api/v1/status/buffers |
read-only | no |
GET |
/api/v1/status/queues |
read-only | yes |
GET |
/api/v1/status/queues/:queue |
read-only | yes |
GET |
/health |
public | no |
GET |
/metrics |
public | no |
GET |
/metrics/prometheus |
public | no |
GET |
/status |
read-only | no |
Streams
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
POST |
/streams/v1/cycle |
read-write | yes |
POST |
/streams/v1/queries |
read-write | yes |
POST |
/streams/v1/state/get |
read-only | yes |
Key/value state and timers
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
POST |
/api/v1/kv |
read-write | yes |
DELETE |
/api/v1/kv/:ns/*key |
read-write | yes |
GET |
/api/v1/kv/:ns/*key |
read-only | yes |
PUT |
/api/v1/kv/:ns/*key |
read-write | yes |
POST |
/api/v1/resources/kv/list |
read-only | yes |
GET |
/api/v1/resources/kv/namespaces |
read-only | yes |
POST |
/api/v1/timers |
write-only | yes |
GET |
/api/v1/timers/:queue |
read-only | yes |
DELETE |
/api/v1/timers/:queue/*timerKey |
read-write | yes |
GET |
/api/v1/timers/:queue/*timerKey |
read-only | yes |
Ephemeral queues
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
POST |
/api/v1/ephemeral/ack |
read-write | yes |
POST |
/api/v1/ephemeral/configure |
read-write | yes |
GET |
/api/v1/ephemeral/pop |
read-write | yes |
POST |
/api/v1/ephemeral/push |
write-only | yes |
DELETE |
/api/v1/ephemeral/queue/:queue |
read-write | yes |
GET |
/api/v1/ephemeral/queues |
read-only | yes |
GET |
/api/v1/ephemeral/queues/:queue/depth |
read-only | yes |
POST |
/api/v1/ephemeral/reset |
read-write | yes |
Operator surfaces
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
GET |
/api/v1/system/ephemeral |
admin | no |
POST |
/api/v1/system/ephemeral |
admin | no |
GET |
/api/v1/system/kv-timers |
admin | no |
POST |
/api/v1/system/kv-timers |
admin | no |
GET |
/api/v1/system/maintenance |
admin | no |
POST |
/api/v1/system/maintenance |
admin | no |
GET |
/api/v1/system/maintenance/pop |
admin | no |
POST |
/api/v1/system/maintenance/pop |
admin | no |
GET |
/api/v1/system/shared-state |
admin | no |
Dashboard identity (broker-direct)
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
GET |
/auth/login |
public | no |
POST |
/auth/logout |
public | no |
GET |
/auth/me |
public | no |
Internal (broker-to-broker)
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
GET |
/internal/api/inter-instance/stats |
admin | no |
POST |
/internal/api/notify |
admin | yes |
GET |
/internal/api/shared-state/stats |
admin | no |
Ungrouped
| Method | Path | Access level | Tenant-scoped |
|---|---|---|---|
POST |
/api/v1/fetch |
read-only | yes |
POST |
/api/v1/partitions/changed |
read-only | yes |
69 of these handlers resolve a tenant from the request; 15 /api/v1/* routes do not and are therefore cell-wide reads or operator surfaces.