One producer, one consumer, one queue. This is the loop everything else on the site is built out of, so it is worth reading the mechanism and not only the code: four broker rules decide what a failure looks like, and all four are visible in this example.
Creating the queue
You do not have to. A push to an unknown queue provisions the queue, its Default partition and its configuration row inside the same transaction that writes the message.
POST /api/v1/configure is the explicit path, and the one to use when you want non-default settings. It is a full replace: every key you omit is reset to its default, including dedupWindowSeconds, which reverts to 3600. Send the complete option set every time you configure a queue, not a patch.
// Illustrative, not extracted from a test.
await client.queue('orders').create()Pushing
A push takes an array of items. Each item carries a payload and, optionally, a transactionId and a partition; the client SDKs generate a transactionId for you when you leave it out.
const res = await client
.queue('test-queue-v2')
.push([{ data: { message: 'Hello, world!' } }])res = await client.queue("test-queue-v2").push([{"data": {"message": "Hello, world!"}}])responses, err := client.Queue(queueName).Push(payload).Execute(ctx)
if err != nil {
t.Fatalf("Failed to push message: %v", err)
}The response is one result per item, in request order, at HTTP 201:
[{"index":0,"message_id":"0198...","transaction_id":"0198...","queueName":"orders","status":"queued"}]status is queued for a message that was written, duplicate for one the deduplication window rejected (see Idempotent producers), and buffered when the broker is in maintenance mode and diverted the push to its disk spool.
Consuming
A pop claims a span, it does not remove rows. The broker takes a lease on the offset range (committed, batch_end] for one (partition, consumer group) pair, hands you the frames, and waits for an ack.
const res = await client
.queue('test-queue-v2-pop-non-empty')
.batch(1)
.wait(true)
.pop()res = await client.queue("test-queue-v2-pop-non-empty").batch(1).wait(True).pop()wait(true) is a long poll: the request parks on the queue’s wake gate and a push wakes it immediately, so an idle consumer costs the broker a timer rather than a spin loop. An empty result comes back as HTTP 204 with no body at all. The SDKs turn that into an empty array.
Each message in the response carries everything the ack needs:
{
"id": "0198...",
"transactionId": "0198...",
"traceId": null,
"data": {"message": "Hello, world!"},
"producerSub": null,
"createdAt": "2026-07-30T09:12:41.118Z",
"partitionId": "6f1c...",
"partition": "Default",
"leaseId": "0198...",
"consumerGroup": "__QUEUE_MODE__"
}consumerGroup reports __QUEUE_MODE__ when the request named no group. That is the literal group name the broker uses for the unnamed default, and it is a real cursor like any other.
The consume loop
The SDKs wrap pop, dispatch and ack into one call. each() invokes the handler per message; without it the handler receives the batch. The handler returning normally acks; the handler throwing nacks.
await client
.queue('test-queue-v2-consume')
.batch(1)
.limit(1)
.each()
.consume(async msg => {
msgToReturn = msg
})Add a group and the consume loop becomes a named cursor that survives restarts:
// Illustrative, not extracted from a test.
await client
.queue('orders')
.group('billing')
.batch(50)
.consume(async messages => {
for (const msg of messages) await handle(msg)
})Acknowledging
Ack is a cursor commit, not a per-message flag. There is one integer per (partition, consumer group): queen.log_consumers.committed, the last acknowledged offset. Four consequences follow, and they are the whole model:
-
Acking message N completes every earlier unacked message in that partition for that group. The cursor moves to the highest acked-ok offset in the batch; anything below it is done and will never be redelivered. “Ack the last message of the batch” is a complete batch ack.
-
An explicit failure is never skipped by a later success. If one call acks message 5 as
failedand message 9 ascompleted, the cursor clamps below 5. Messages 6 to 9 redeliver. That is an at-least-once duplicate, which the design prefers to a silently swallowed nack. At the same offset,dlqbeatsfailedbeatsretry. -
An ack that can no longer do anything is reported, not faked. An ack whose offset is at or below the cursor comes back
noop: truefor a completion, or rejected withalready committedfor a failure signal. An ack the broker cannot resolve at all (the transaction id fell out of the hash window, or never existed) is rejected withunresolvable. The cursor did not move in either case, so the frames redeliver. -
Exactly one leased batch exists per
(partition, group)at a time. A second consumer in the same group popping the same partition gets nothing until the first lease is acked or expires. A single-partition queue therefore cannot be consumed in parallel by one group; parallelism comes from partitions.
The wire response is one item per acknowledgment, in request order, at HTTP 200:
[{"index":0,"transactionId":"0198...","success":true,"error":null,"leaseReleased":true,"dlq":false,"noop":false}]Leases and expiry
The lease has a deadline. It is 60 seconds on a queue that a push created implicitly, and 300 seconds after an explicit /configure (the configure default for leaseTime); ?leaseSeconds=N on the pop overrides both.
When the deadline passes with no ack, the lease is simply gone: the cursor stays where it was and the whole span redelivers. Lease expiry never charges the retry budget: only an explicit failed ack does. A handler that is slow rather than broken can therefore loop forever without ever reaching the dead-letter queue, so extend the lease instead of relying on expiry:
POST /api/v1/lease/{leaseId}/extend
Content-Type: application/json
{"seconds": 120}The call renews every live lease held by that lease id (a multi-partition pop shares one), never shortens one, and always answers HTTP 200: renewal is best-effort, so read the response rather than the status.
At-most-once, if you want it
?autoAck=true commits the cursor inside the pop transaction. There is no lease and no ack round trip, and a crash after the response leaves the message consumed and unprocessed. That is at-most-once, and it is the right choice only when losing a message is cheaper than handling it twice.
What to expect when things break
| Situation | What the broker does | What you see |
|---|---|---|
| Consumer crashes before acking | Lease expires, cursor unchanged | The whole span redelivers; retry budget untouched |
| Handler throws, SDK nacks | Cursor clamps below the failed offset, lease released, budget charged once | Redelivery of the failed message and everything after it in the batch |
| Ack arrives after the lease expired | Rejected under the consumer row lock | HTTP 200 with success: false, invalid or expired lease |
| Two consumers, one group, one partition | Second pop finds the row leased and skips it | Empty result until the lease clears |
| PostgreSQL unreachable during a push | Push is appended to the disk spool and replayed later | HTTP 201 with status: "buffered" |