An acknowledgement is not a per-message delete. Consumption state is one cursor per
(partition, consumer group), and an ack commits that cursor. Acking message N therefore
implicitly completes every earlier unacked message in the same partition for the same group:
“ack the last message of the batch” completes the batch. There is no per-message delivery state
to store, clean up or leak.
On the wire an ack is addressed by transactionId, which the broker resolves to an offset
through the partition’s transaction index. Both routes have access level read-write and both
answer 200 with a top-level array, one element per request item, in request order.
Request
Single
POST /api/v1/ack
{
"transactionId": "order-8891-created",
"partitionId": "b1c2d3e4-5f60-7180-9a0b-1c2d3e4f5061",
"consumerGroup": "billing",
"leaseId": "0198f2c1-9a7b-7d20-8e31-4c5d6e7f8a90",
"status": "completed"
}| Field | Type | Required | Default |
|---|---|---|---|
transactionId |
string | in practice yes | empty string, which resolves to nothing |
partitionId |
string (UUID) | yes | empty string, which fails in the database |
status |
string | no | completed |
consumerGroup |
string | no | __QUEUE_MODE__ |
leaseId |
string | no | empty, meaning a lease-less ack |
error |
string | no | absent |
Batch
POST /api/v1/ack/batch
{
"consumerGroup": "billing",
"acknowledgments": [
{ "transactionId": "order-8891-created", "partitionId": "b1c2…5061", "status": "completed", "leaseId": "0198…8a90" },
{ "transactionId": "order-8892-created", "partitionId": "b1c2…5061", "status": "failed", "leaseId": "0198…8a90", "error": "downstream 502" }
]
}The array key is acknowledgments (American spelling, no e after the g). consumerGroup is
top-level only; per-item consumerGroup is not read. Each item carries its own partitionId,
status, leaseId and error.
error is the failure reason. It is not stored for a plain nack; it is recorded on the
dead-letter row if this nack is the one that dead-letters the message. When several nacks in a
group carry reasons, the one whose transactionId matches the poison message wins, then the
first available reason, then the default Retries exhausted.
Status values
The broker normalises status before it reaches SQL:
| You send | Treated as |
|---|---|
absent or null |
completed |
completed, success, acked, ok |
completed |
retry |
retry |
dlq |
dlq |
anything else, including "", Completed, FAILED, nack |
failed |
Matching is exact and case-sensitive. There is no validation error for an unrecognised status. It silently becomes a nack, which is the most consequential default in the API. Send one of the four documented spellings.
Leases are optional, and that has teeth
- With a
leaseId, the ack is validated against the live lease: a mismatched, expired or already-released lease is refused withinvalid or expired leaseand the cursor does not move. - Without a
leaseId, validation is skipped and the cursor advances anyway. That is what makes a post-expiry ack succeed, and it also means a lease-less ack can advance a cursor while another worker holds the batch. Send theleaseIdyou popped with unless you specifically want the unvalidated behaviour.
Items are grouped by (partitionId, leaseId) and each group is resolved in its own database
call, so a batch spanning three partitions performs three resolutions, and a failure in one group
does not affect the others.
Response
200 OK, a top-level array:
[
{
"index": 0,
"transactionId": "order-8891-created",
"success": true,
"error": null,
"leaseReleased": true,
"dlq": false,
"noop": false
}
]| Field | Type | Meaning |
|---|---|---|
index |
integer | the item’s position in the request |
transactionId |
string | echoed back, so you can map results without relying on order alone |
success |
boolean | the broker carried out the instruction you sent (see below) |
error |
string or null |
why this item was refused |
leaseReleased |
boolean | this ack ended the lease on that (partition, group); the partition is claimable again |
dlq |
boolean | this ack caused a dead-letter row to be filed |
noop |
boolean | the ack was a harmless duplicate: it resolved at or below the committed cursor and changed nothing |
The response does not carry the new cursor value, the remaining retry budget, or how many messages were implicitly completed. Those are internal; the inspection routes expose them.
What each status does
Assume a batch was delivered as offsets 10–19 with the cursor at 9.
completed
The cursor advances to the highest completed offset in the call, and every unacked offset below
it is completed too. If the cursor reaches the leased batch’s end, the lease is released and the
retry state is reset (leaseReleased: true); if it does not, the lease is kept so you can
ack the rest of the batch.
failed
An explicit failure. The cursor is clamped just below the failed offset, so the failed message and everything after it in the batch redelivers. Then:
| Retry budget | Queue’s DLQ setting | Outcome |
|---|---|---|
| remaining | any | the counter is charged once, the lease is released, and the tail redelivers |
| exhausted | DLQ enabled (the default) | the message is dead-lettered: a row is filed with your error as the reason, the cursor moves past it, the lease is released (dlq: true) |
| exhausted | DLQ disabled | the poison message is dropped: the cursor advances past it, the counter resets, the lease is released, and dlq stays false. The message is gone and nothing recorded it |
Dead-lettering is disabled only when both deadLetterQueue and dlqAfterMaxRetries are
explicitly false on the queue; either one left at its default keeps it on. An unconfigured
queue dead-letters.
The budget is retryLimit, default 3, counted per (partition, group) and reset when a
batch completes. It is charged only by an explicit failed ack. A lease that merely expires
redelivers without consuming any of it, so a crash-looping consumer never exhausts a budget by
crashing, only by nacking.
retry
A budget-free retry. The lease is released, the cursor sits at the completed prefix, the retry counter is untouched, and the tail redelivers. Use it for “not my turn yet” conditions (a missing dependency, a rate-limited downstream) that should not count against the message’s life.
dlq
Immediate dead-letter, bypassing whatever budget is left. The cursor advances over the completed
prefix, the poison message’s payload is snapshotted into the dead-letter table, the cursor then
moves past it and the lease is released. dlq: true.
Explicit signals are never skipped
Both of these are contract-tested. In one ack call:
- The cursor is clamped at the lowest explicit
failed/retry/dlqposition, even when a later offset in the same call was ackedcompleted. Those later completed messages redeliver. At-least-once duplicates are acceptable; a silently swallowed nack is not. - At the same offset the precedence is
dlq>failed>retry. - Only silent gaps (offsets you did not mention at all, below the highest completed one) are implicitly completed.
So a handler that acks messages individually and then fails one does not need to withdraw the earlier acks: the failure wins from its own offset onward.
Acks that cannot do anything
An ack can arrive too late or address something the broker cannot resolve. In both cases it is reported, never silently reported as success.
| Situation | Result |
|---|---|
completed ack that resolves at or below the cursor |
success: true, noop: true (a duplicate commit, harmless) |
failed, retry or dlq that resolves at or below the cursor |
success: false, error: "already committed: the cursor moved past this message before this ack". The signal can no longer be honoured, and pretending otherwise would lose the nack |
a transactionId that resolves nowhere: never pushed, or its index entry has been purged |
success: false, error: "unresolvable: transaction not in the ack window (hash purged or never pushed); if leased, the message redelivers" |
The last one is the one to design against. The transaction index is purged on its own clock, so an ack that arrives long after delivery (a handler that ran for hours, a queued retry) may find nothing to resolve. The cursor does not move, the message redelivers, and if you were nacking it, that nack charged nothing and dead-lettered nothing. Without this report, the client would believe the ack landed while the cursor never moved: a silent redelivery livelock. Ack while your lease is alive, extending it if you need to.
Error strings you can encounter
error |
Meaning |
|---|---|
consumer not found |
this group has no cursor on that partition: it never popped from it |
invalid or expired lease |
a leaseId was supplied and it is not the live lease |
already committed: … |
see the table above |
unresolvable: … |
see the table above |
ack flush failed; lease will expire and redeliver |
the commit that would have carried this ack failed; nothing was committed, and the lease expiry redelivers the batch |
pool |
no database connection was available for this group |
partition not owned by tenant |
multi-tenant deployments only: the partitionId belongs to another tenant |
| a raw database message | the group’s resolution query failed. An invalid partitionId that is not a UUID surfaces here |
Status codes
| Code | When |
|---|---|
200 |
the call was processed, whatever the per-item outcomes |
400 |
the body is not JSON: {"error":"bad body: …"} |
403 |
authentication is on and the token has no read-write role |
There is no 404 for an unknown transactionId and no 409 for a stale lease: both are per-item
outcomes inside a 200.
Acknowledging in a transaction
If the next thing your handler does is write another message, do the ack and the push in one
PostgreSQL transaction instead of two calls. See
POST /api/v1/transaction. That is how a multi-stage pipeline
hands work forward without a window in which the ack landed and the next message did not.