Queen has no binary protocol and no wire framing. Every SDK is a wrapper over JSON-over-HTTP, so anything that can make an HTTP request is a first-class client. The whole message plane is eight routes:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/push |
Append messages |
GET |
/api/v1/pop/queue/:queue |
Lease a batch from a queue |
GET |
/api/v1/pop/queue/:queue/partition/:partition |
Lease a batch from one partition |
GET |
/api/v1/pop |
Lease across a namespace or task |
POST |
/api/v1/ack |
Commit or fail one message |
POST |
/api/v1/ack/batch |
Commit or fail many |
POST |
/api/v1/transaction |
Push and ack atomically |
POST |
/api/v1/lease/:leaseId/extend |
Extend a lease |
Everything else (queue configuration, DLQ, traces, status, analytics, consumer group management) is documented with its access level in Reference.
Conventions
Base URL. http://host:6632 by default (PORT). The broker has no TLS listener: for
HTTPS you terminate TLS in front of it, either with the Queen proxy or with any reverse proxy.
There is no CORS layer either, so a browser cannot call the broker directly across origins.
Content type. Send Content-Type: application/json on every request with a body.
Responses are application/json (including /metrics, which returns a JSON snapshot) except
/metrics/prometheus, which is text/plain; version=0.0.4.
Authentication. One header:
Authorization: Bearer <token>The broker accepts a bare token with no Bearer prefix as well. When JWT auth is enabled, the
token’s roles decide which routes you may call, and the access level of every route is
generated into Reference.
Errors. Any failure is {"error":"..."} with an appropriate status. A malformed body is
400 with the parser’s message included. At a multi-tenant proxy, a 429 also carries
{"code":"rate_limited"} or {"code":"quota_exceeded"} plus a Retry-After header in seconds,
and a 403 carries {"code":"cluster_suspended"|"storage_quota_exceeded"|"feature_gated"|"forbidden"}.
Branch on code, not on the message text.
The bodiless 204. An empty pop answers 204 No Content with no body at all, not an
empty JSON object, not a zero-length body with a Content-Length. This is deliberate:
announcing a content length on a body that hyper then elides made strict HTTP/1.1 clients
(Node’s undici/llhttp in particular) treat the connection as poisoned and drop it, which under
empty-poll load snowballed into connection-reset storms. Your client must treat 204 as “no
messages” and never try to parse it.
Many management responses are stored-procedure JSON passed through verbatim, so their shapes are the SQL’s, not a hand-written DTO’s.
Push
POST /api/v1/push
{
"items": [
{
"queue": "orders",
"partition": "acct-42",
"transactionId": "order-1-created",
"payload": { "id": 1, "total": 1999 }
}
]
}queue and payload are required per item. partition defaults to Default. transactionId
is optional: the broker mints a UUIDv7 when it is absent, which makes the push
non-idempotent. Supply a deterministic one to make a retry safe inside the queue’s dedup
window.
The response is 201 Created and a top-level array, one entry per item in request order:
[
{
"index": 0,
"message_id": "0197f2b1-...",
"transaction_id": "order-1-created",
"queueName": "orders",
"status": "queued"
}
]Note the mixed casing: message_id and transaction_id are snake_case, queueName is
camelCase.
status |
Meaning |
|---|---|
queued |
Appended and committed. |
duplicate |
The (queue, partition, transactionId) already exists inside the dedup window. Nothing was written; message_id is the original message’s id. |
buffered |
The database write failed and the message went to the broker’s disk spool for replay. |
failed |
The database write failed and the spool write failed too. This message is lost. |
Duplicates are also collapsed within one request: repeat a (queue, partition, transactionId) inside the same items array and the first occurrence wins, the rest come back
as duplicate carrying the winner’s message_id. An empty items array is 201 with [].
Pop
GET /api/v1/pop/queue/:queue, or .../partition/:partition to pin one lane, or
/api/v1/pop?namespace=…&task=… to lease across every queue matching a namespace or task (at
least one of the two is required there, otherwise it is a 400).
| Query parameter | Default | Effect |
|---|---|---|
batch |
1 | Maximum messages returned. |
partitions |
1 | Claim up to N partitions in one call. batch becomes the global cap across all of them, and every claimed partition shares one leaseId. |
wait |
false | Long-poll: park the request until a message arrives or the timeout expires. |
timeout |
30000 ms | Long-poll window in milliseconds. The server-side default comes from DEFAULT_TIMEOUT, then POP_DEFAULT_TIMEOUT_MS, then 30000. |
consumerGroup |
queue mode | The group whose cursor advances. |
autoAck |
false | Commit the cursor inside the pop transaction. At-most-once. |
leaseSeconds |
queue’s leaseTime |
Per-request lease override, in seconds. No SDK exposes this. |
subscriptionMode |
all |
all or new, applied only when this (partition, group) cursor is created. |
subscriptionFrom |
none | now or an ISO timestamp, same first-contact-only rule. |
A successful pop is 200:
{
"success": true,
"queue": "orders",
"partition": "acct-42",
"partitionId": "0197f2b0-...",
"leaseId": "0197f2b2-...",
"consumerGroup": "billing",
"messages": [
{
"id": "0197f2b1-...",
"transactionId": "order-1-created",
"traceId": null,
"data": { "id": 1, "total": 1999 },
"producerSub": null,
"createdAt": "2026-07-30T09:12:44.031Z",
"partitionId": "0197f2b0-...",
"partition": "acct-42",
"leaseId": "0197f2b2-...",
"consumerGroup": "billing"
}
]
}An empty pop is 204 with no body.
Points worth internalising:
- The top-level
partitionandpartitionIdare the first claimed partition’s. Withpartitions>1the batch spans several lanes, so read each message’s ownpartitionId. - All of the messages share one
leaseId, even across partitions. One extend call renews the whole claim. producerSubis the authenticated producer’s JWTsub, stamped by the broker at push time. A client cannot set it: that is how impersonation is prevented. It isnullwhen JWT auth is off.createdAtis the segment’s timestamp: every message written by one push call shares it.- There is no per-message
queue,retryCountorerrorMessagefield on a pop. Those appear on DLQ rows. - Exactly one in-flight leased batch exists per
(partition, group). A single-partition queue therefore cannot be drained in parallel by one group, no matter how many workers poll it.
Acknowledge
POST /api/v1/ack
{
"transactionId": "order-1-created",
"partitionId": "0197f2b0-...",
"leaseId": "0197f2b2-...",
"status": "completed",
"consumerGroup": "billing",
"error": null
}POST /api/v1/ack/batch
{
"consumerGroup": "billing",
"acknowledgments": [
{ "transactionId": "order-1-created", "partitionId": "0197f2b0-...", "leaseId": "0197f2b2-...", "status": "completed" },
{ "transactionId": "order-2-created", "partitionId": "0197f2b0-...", "leaseId": "0197f2b2-...", "status": "dlq", "error": "unparseable payload" }
]
}partitionId is required: a transactionId is not unique across partitions. consumerGroup
defaults to __QUEUE_MODE__, the internal name for queue mode. error is the failure reason
recorded on the DLQ row if this nack exhausts the retry budget.
status is normalised server-side. completed, success, acked, ok and an absent status
all mean completed; retry means retry; dlq means dlq; anything else at all means
failed. A typo does not error: it nacks.
Both routes answer 200 with a top-level array in request order, whatever the outcome:
[
{ "index": 0, "transactionId": "order-1-created", "success": true, "error": null,
"leaseReleased": true, "dlq": false, "noop": false }
]noop: true means the ack was recognised but changed nothing: it landed below the cursor
(“already committed”) or outside the transaction-hash window (“unresolvable”). That is reported
rather than silently succeeding, which is the point: an ack you cannot resolve is not an ack
that worked.
Ack is an offset commit
There is no per-message delivery state. Consumption state is one cursor per (partition, consumer group), and acking message N implicitly completes every earlier unacked message in
that partition for that group. Two consequences shape how you write a consumer:
- Acking out of order silently completes the gap. If you ack offset 10 while 7, 8 and 9 are unacked, they are done too.
- A negative ack clamps the cursor. Within one call the cursor stops at the lowest signal,
and
dlqbeatsfailedbeatsretryat the same offset. An explicitfailed/dlq/retryis never skipped by a latercompletedin the same call. So everything after the failed message in that batch will be redelivered. Do not keep processing it.
Extend a lease
POST /api/v1/lease/:leaseId/extend
{ "seconds": 60 }An empty body defaults to 60 seconds. The response is always 200, because renewal is
best-effort:
{ "renewed": 2, "expiresAt": "2026-07-30T09:14:44.031Z" }renewed is how many (partition, group) lease rows the extend touched (one per claimed
partition), and expiresAt is the earliest deadline across them, which is what you need to
schedule the next renewal. {"renewed":0,"expiresAt":null} means the lease is unknown, expired,
or not yours.
Transactions
POST /api/v1/transaction bundles pushes and acks into a single PostgreSQL transaction, all or
nothing:
{
"operations": [
{ "type": "push", "items": [ { "queue": "stage-2", "partition": "acct-42", "payload": { "value": 2 } } ] },
{ "type": "ack", "transactionId": "order-1-created", "partitionId": "0197f2b0-...", "status": "completed", "consumerGroup": "billing" }
],
"requiredLeases": ["0197f2b2-..."]
}requiredLeases makes the commit fail if any of those leases expired in the meantime, which is
what stops a stale worker from acking a batch someone else now owns.
A complete walkthrough
-
Check the broker is up.
/healthdoes a real database round-trip, so a200means PostgreSQL is reachable too.curl -s http://localhost:6632/health -
Push two messages into one partition. The queue and the partition are created on first push: there is no declaration step.
curl -s -X POST http://localhost:6632/api/v1/push -H 'Content-Type: application/json' -d '{"items":[{"queue":"demo","partition":"acct-42","transactionId":"evt-1","payload":{"n":1}},{"queue":"demo","partition":"acct-42","transactionId":"evt-2","payload":{"n":2}}]}' -
Push
evt-1again. It comes backduplicatewith the originalmessage_id, and nothing is written.curl -s -X POST http://localhost:6632/api/v1/push -H 'Content-Type: application/json' -d '{"items":[{"queue":"demo","partition":"acct-42","transactionId":"evt-1","payload":{"n":1}}]}' -
Lease both messages for the
billinggroup, long-polling for up to five seconds. Keep theleaseIdand each message’spartitionId.curl -s 'http://localhost:6632/api/v1/pop/queue/demo?consumerGroup=billing&batch=10&wait=true&timeout=5000' -
Acknowledge both. Because an ack is an offset commit, acking
evt-2alone would also completeevt-1. Send both when you have processed both.curl -s -X POST http://localhost:6632/api/v1/ack/batch -H 'Content-Type: application/json' -d '{"consumerGroup":"billing","acknowledgments":[{"transactionId":"evt-1","partitionId":"<partitionId>","leaseId":"<leaseId>","status":"completed"},{"transactionId":"evt-2","partitionId":"<partitionId>","leaseId":"<leaseId>","status":"completed"}]}' -
Pop again. The cursor is past both messages, so this returns
204with an empty body, which-imakes visible.curl -si 'http://localhost:6632/api/v1/pop/queue/demo?consumerGroup=billing&batch=10' -
Pop as a different group.
analyticshas its own cursor, created on first contact, so it sees both messages again.curl -s 'http://localhost:6632/api/v1/pop/queue/demo?consumerGroup=analytics&batch=10'
Writing your own client
The behaviours the five SDKs implement, in the order they matter:
- Send
Authorization: Bearerand nothing else. No API-key header, no query-string token. - Treat
204as “no messages” and never parse its body. - Read per-item statuses. A
201on a push and a200on an ack both carry per-item verdicts that can disagree with the status code. Check the array length against what you sent. - Back off on
429, in place. HonourRetry-Afterin seconds; otherwise exponential backoff from 500 ms capped at 30 s, with jitter. Ten attempts for an ordinary request; unbounded for a long-poll pop, whose outer loop is already an indefinite wait. Never fail over to a different backend on a429: it is a quota signal, not a health signal. - Stop on
403.cluster_suspended,storage_quota_exceeded,feature_gatedandforbiddencannot resolve themselves. - Give the long poll client-side slack. The SDKs set their HTTP timeout to
timeout + 5000ms so the server-side long-poll timeout always fires first and the socket is reused instead of aborted. - Never send
autoAckwhen you intend to ack yourself.autoAck=truecommits the cursor inside the pop transaction; a crash after the response is a lost message. - Abandon the rest of a batch after a negative ack, because the cursor clamped at the failed message and everything after it is coming back.
- Pin a lane to a backend if you run several brokers. The SDKs hash
queue:partition:consumerGroup(ornamespace:task:consumerGroup) onto a ring so the same lane keeps hitting the same broker, which keeps its long-poll wake-ups local. - Set a generous idle connection pool. Language defaults are often 2 per host, which forces a TCP and TLS handshake per request under concurrency.