POST /api/v1/transaction is the call Queen was built around. It writes a worker’s whole step
(the acks of what it took, the events it produces, the KV state it changes, the timers it sets,
where a consumer group should read next) as one entry of the replicated log, so all of it applies
or none of it does. There is no outbox table to poll and no idempotency table to consult before
you act: the step and its effects are the same write.
It answers HTTP 200 both when it commits (success: true) and when it rolls back
(success: false with a reason), because a rollback is a verdict about your data, not a failure
of the call. This page is every field, every result and every reason. For the model and the
patterns built on it, read transactions first.
Request
{
"operations": [], // { "type": "ack" | "push", ... }, in order
"requiredLeases": [], // lease ids, strings
"kv": [], // { "op": "put" | "putIfAbsent" | ..., ... }
"timers": [], // { "op": "schedule" | "reschedule" | "cancel", ... }
"positions": [] // { "queue", "consumerGroup", "partition", "offset" }
}| Field | Type | Meaning |
|---|---|---|
operations |
array | Acks and pushes, in order. Each element has type: "ack" or type: "push". |
requiredLeases |
array of strings | Lease ids for the acks that name none. See how an ack finds its lease. |
kv |
array | KV operations. Top level, never inside operations. |
timers |
array | Timer schedules and cancels. Top level, never inside operations. |
positions |
array | Set or forget where a consumer group reads a partition. |
At least one of operations, kv, timers and positions must be non-empty. A kv or timer
element inside operations is refused with 400, and so is a top-level field that is present but
not an array.
Push operation
Either a list of items, or one item inline (queue, partition, payload on the operation).
| Field | Required | Meaning |
|---|---|---|
items[].queue |
yes | Queue name. A queue that does not exist is created. |
items[].partition |
no | Partition name, default Default. A partition that does not exist is created. |
items[].payload |
yes | Any JSON value. |
items[].transactionId |
no | The dedup key, at most 65,535 bytes. Default: the minted message id. |
items[].traceId |
no | A UUID; an invalid one is ignored. |
Two items with the same (queue, partition, transactionId) in one call store one message; the
second result says duplicate: true. An item whose transactionId is already in the partition’s
dedup window rolls the whole transaction back with reason: "duplicate".
Ack operation
| Field | Required | Meaning |
|---|---|---|
transactionId |
yes | The popped message’s transactionId. |
partitionId |
yes | The popped message’s partitionId: a JSON string of digits, as the pop returns it. |
consumerGroup |
no | The group the message was popped for. Default __QUEUE_MODE__ (queue mode). |
leaseId |
no | The pop’s leaseId. |
status |
no | completed (default), failed, retry or dlq. Any other value reads as completed. |
error |
no | The reason recorded on a dead letter (failed, dlq). |
failed counts one attempt: the message is redelivered while the queue’s retryLimit lasts, then
dead-lettered (or dropped with deadLetterQueue: false). retry releases it for redelivery
without counting an attempt. dlq dead-letters it now.
How an ack finds its lease
Each ack gets at most one lease, found in this order:
- its own
leaseId; - otherwise the one lease named by
requiredLeasesand the other acks’leaseIds together, when they all name the same lease; - otherwise none.
An ack with a lease is fenced. The lease must be the one the partition holds for that group, not
expired, and the message must be inside the leased batch. If not (the lease expired, the message
went to another worker, an earlier commit already acked it and released the lease), the whole
transaction rolls back with reason: "rejected_ack".
An ack with no lease is not fenced. The broker skips the lease check: it acks the message even
if another worker holds it. It still rolls back with rejected_ack when the message is not found
after the group’s cursor, or was already acked with a failed, retry or dlq status. A
completed ack of a message that was already acked is a no-op, and the rest of the transaction
commits.
requiredLeases fences nothing on its own: it only supplies the lease of the acks in the same
body. Every SDK puts each acked message’s leaseId on its ack as well as in requiredLeases, so a
transaction that acks messages from two different pops is fenced by both leases. Over HTTP, put
leaseId on every ack.
KV operations
The same operations as POST /api/v1/kv, without getPrefix.
op |
Fields |
|---|---|
get |
ns, key |
getMany |
ns, keys (array) |
put |
ns, key, value, expiry, expect?, required? |
putIfAbsent |
ns, key, value, expiry, required? (it is put with expect: 0) |
delete |
ns, key, expect?, required? |
incr |
ns, key, delta (number), min?, max?, expiry, required? |
Every put, putIfAbsent and incr carries exactly one expiry: ttlSeconds (an integer greater
than 0) or forever: true; the JS SDK turns ttl: '30d' into ttlSeconds. A value is any JSON
value of at most QUEEN_KV_MAX_VALUE_BYTES (64 KiB) as compact JSON. expect is a version number,
and 0 means the key must not exist.
required: true turns an operation into a gate. If its precondition loses (exists, absent,
version, limit, type), the whole transaction rolls back with reason: "kv_precondition".
Without required, a lost precondition is applied: false in the results and the rest commits.
The JS .once(ns, key) is putIfAbsent with required: true.
ns matches ^[a-z0-9][a-z0-9._-]{0,63}$, a key is 1 to 512 bytes with no NUL, and a key may be
written once per call. A transaction carries at most 64 KV operations naming at most 256 keys.
Timer operations
| Field | Required | Meaning |
|---|---|---|
op |
yes | schedule, reschedule (the same upsert) or cancel. |
queue |
yes | The queue the message is pushed to when the timer fires. |
timerKey |
yes | The timer’s name, unique per queue. Scheduling an existing key replaces it. |
partition |
no | Partition of the fired message, default Default. |
delayMs |
schedule | A delay in milliseconds, counted on the leader when it plans the transaction. Zero or negative fires on the next tick. |
txn |
schedule | The fired message’s transactionId. On a cancel, echoed back. |
payload |
schedule | The message payload, base64. |
payloadZstd |
no | true when payload is zstd-compressed; the broker decompresses it. |
The broker owns producerSub, messageId, deliverAt, attempts, the tenant fields and every
field starting with _: a timer op that carries one is refused with 400. A (queue, timerKey) may
appear once per call.
Positions
| Field | Required | Meaning |
|---|---|---|
queue |
yes | Must exist, or the transaction rolls back with queue_not_found. |
consumerGroup |
yes | The group. It is registered on the queue if it is new. |
partition |
no | Default Default. A partition that does not exist is created. |
offset |
yes | The next offset the group reads, or null to forget the position. |
metadata |
no | A string of at most 4096 bytes, stored with the position. |
subscriptionMode |
no | How a new group is registered, default DEFAULT_SUBSCRIPTION_MODE. |
A position set here releases any lease the group holds on that partition. At most 16,384 per call.
Response
{ "transactionId": "0199a7c2-61f0-7b02-9a4c-0e5d3f2b8c11", "success": true, "results": [] }results has one element per part, in one index space: the operations first, in order, then
kv, then timers, then positions. Every element carries index and type; KV, timer and
position results also carry opIndex, their place in their own array. The top-level
transactionId is minted for this call; it is not a dedup key.
type |
Fields |
|---|---|
ack |
success, transactionId, error, dlq (true when the ack dead-lettered the message) |
push |
success, transactionId, messageId, queueName, and duplicate: true for an in-call repeat |
kv |
op, applied, reason (when not applied), key, value, version; a read has found or rows/missing |
timer |
ok, status (scheduled, rescheduled, cancelled, or absent with ok: false), queue, timerKey, txn, messageId, deliverAt |
position |
success |
absent on a cancel means no pending timer has that key: it may already have fired.
Example
An order worker acks the event, records the order state, pushes a receipt, schedules a reminder and gates the whole step on a marker:
curl -s http://localhost:6632/api/v1/transaction \
-H 'Content-Type: application/json' -d '{
"operations": [
{ "type": "ack", "transactionId": "order-7-paid", "partitionId": "42",
"consumerGroup": "billing", "leaseId": "0199a7c2-5e1b-7f3a-8c21-4d9e0b6a1f37" },
{ "type": "push", "items": [
{ "queue": "receipts", "partition": "customer-123",
"transactionId": "receipt-order-7", "payload": { "orderId": "order-7", "total": 120.5 } } ] }
],
"kv": [
{ "op": "put", "ns": "orders", "key": "order-7", "value": { "status": "paid" },
"ttlSeconds": 2592000 },
{ "op": "putIfAbsent", "ns": "paid", "key": "order-7-paid", "value": true,
"ttlSeconds": 86400, "required": true }
],
"timers": [
{ "op": "schedule", "queue": "reminders", "timerKey": "order-7", "delayMs": 86400000,
"txn": "reminder-order-7", "payload": "eyJvcmRlcklkIjoib3JkZXItNyJ9" }
]
}'The commit:
{
"transactionId": "0199a7c2-61f0-7b02-9a4c-0e5d3f2b8c11",
"success": true,
"results": [
{ "index": 0, "type": "ack", "success": true, "transactionId": "order-7-paid",
"error": null, "dlq": false },
{ "index": 1, "type": "push", "success": true, "transactionId": "receipt-order-7",
"messageId": "0199a7c2-61f1-7d55-b0e2-7a1c9e4f6d20", "queueName": "receipts" },
{ "index": 2, "op": "put", "applied": true, "key": "order-7",
"value": { "status": "paid" }, "version": 90101, "opIndex": 0, "type": "kv" },
{ "index": 3, "op": "putIfAbsent", "applied": true, "key": "order-7-paid",
"value": true, "version": 90102, "opIndex": 1, "type": "kv" },
{ "ok": true, "status": "scheduled", "queue": "reminders", "timerKey": "order-7",
"txn": "reminder-order-7", "messageId": "0199a7c2-61f1-7d55-b0e2-8b3f0a2d4e71",
"deliverAt": "2026-10-02T09:14:03.512887Z", "opIndex": 0, "index": 4, "type": "timer" }
]
}If the same event comes back (the group was seeked back and pops it again under a new lease), the
step runs again. Its receipt carries the same transactionId, so the step rolls back and writes
nothing:
{
"transactionId": "0199a7c3-0a2e-7c11-8d40-5f6e7a8b9c02",
"success": false,
"reason": "duplicate",
"error": "QDUP a pushed message to receipts/customer-123 is a duplicate (original offset 17); the transaction rolled back",
"results": []
}Rollback reasons
A rolled-back transaction wrote nothing. The body is
{transactionId, success: false, reason, error, results: []}. When several guards would fire, the
reason is the first in this order: the acks (rejected_ack), the pushes (duplicate), the
positions, the KV operations (kv_precondition), the timers.
A lost required KV operation adds the operation that lost:
{
"transactionId": "0199a7c3-0a2e-7c11-8d40-5f6e7a8b9c02",
"success": false,
"reason": "kv_precondition",
"error": "QKV a required KV precondition failed; the transaction rolled back",
"results": [],
"ok": false,
"failedIndex": 3,
"kvReason": "exists",
"version": 90102,
"value": true
}failedIndex is in the same index space as results. The JS client returns this answer from
commit(); every other success: false throws with error.reason set.
reason |
HTTP | When | What to do |
|---|---|---|---|
kv_precondition |
200 | A required KV operation lost. The body adds failedIndex, kvReason, version, value. |
The step already happened. The ack rolled back with it: ack the input on its own (POST /api/v1/ack). |
duplicate |
200 | A pushed item’s transactionId is already in its partition, inside the queue’s dedupWindowSeconds. |
The step already happened. Ack the input on its own; do not retry. |
rejected_ack |
200 | A fenced ack’s lease is not the partition’s live lease, or its message is outside the leased batch; an ack’s message is not found after the cursor; or the partitionId is unknown or belongs to another tenant. |
Do not retry: the message is redelivered to whoever holds it, or was already done. |
reserved |
200 | Another transaction, or the engine’s checkpoint, holds a cursor this transaction acks or positions. | Retry. |
unavailable |
200 | The leader could not read the state the transaction needs. | Retry. |
queue_not_found |
200 | A position names a queue that does not exist. | Create the queue. |
too_large |
200 | The pushes to one partition, or the positions, exceed QUEEN_RAFT_ENTRY_MAX_BYTES (96 MiB) once planned, or a position’s metadata exceeds 4096 bytes. |
Split the transaction. |
name_too_long, kv_key_too_large |
200 | A name passed the edge check but not the store’s key limit. | Shorten it. |
bad_request |
400 or 200 | 400: the body is not valid JSON of this shape, an operation type is missing or not push/ack, or a timer op is invalid. 200: no parts at all, an inline push without queue or payload, a non-numeric partitionId, an invalid position, or encrypted on a timer for a queue that encrypts at rest. |
Fix the request. |
timers_server_owned_field |
400 | A timer op carries producerSub or a field starting with _. |
Remove it. |
kv_* |
400, 413 | A KV operation fails validation: kv_bad_request, kv_unknown_op, kv_bad_namespace, kv_bad_key, kv_bad_ttl, kv_expiry_not_specified, kv_bad_expect, kv_too_many_ops, kv_too_many_keys, kv_duplicate_key_in_call, kv_tenant_not_an_input, kv_get_prefix_not_allowed_in_transaction; 413 for kv_key_too_large and kv_value_too_large. |
Fix the request. |
internal, entry_encode_failed |
200 | A broker fault. | Report it. |
Some failures use the engine’s error body {"error", "code"} instead, with no reason: 413
name_too_long (a pushed queue or partition name over the key budget), 400 bad_request (a
transactionId over 65,535 bytes), 429 overloaded, 503 retry, no_leader or timeout, 507
storage_full, 500 internal, and the 401/403 of authentication. See
errors.
Retries
Inside one call, the broker retries a leader change itself, under the call’s request id, until the
deadline (QUEEN_STMT_TIMEOUT_MS, 30 s), and a transaction that committed before the change is
answered from its record and does not run again.
Across calls there is no client-supplied request id: a request you send again is a new
transaction, and after a 503 or a lost connection the first attempt may have committed. If it did,
the retry rolls back and writes nothing only because of what the body carries: a fenced ack whose
lease the first commit released (rejected_ack), a pushed transactionId already in the partition
(duplicate), or a required marker (kv_precondition). An ack with no lease does not stop it,
because acking a message twice is a no-op.
Give every pushed item a deterministic transactionId, or gate the step with a required
marker, so that a retry is harmless. A transaction made only of KV writes and timers, with no
gate, runs again when retried: an incr counts twice, and a timer that already fired is scheduled
again. The JS client retries 5xx answers itself (three attempts by default), so this applies to
SDK calls too. See dedup.
Limits
- One call, one tenant. There is no interactive BEGIN and COMMIT: a transaction cannot read a value
and branch on it.
expectandrequiredare the conditions it can carry. - The timer horizon (
QUEEN_TIMERS_MAX_HORIZON_S, 90 days), the 1 MiB timer payload ceiling and the 256-op cap ofPOST /api/v1/timersare not applied to thetimersarray of a transaction. - Throughput and commit latency, measured against Kafka, Redpanda and Pulsar on the same machines, are on the transactions benchmark.
- An external side effect, such as an HTTP call to a payment provider, is outside the commit.