Secret payloads
What to do when a job has to carry a short-lived bearer value your broker should never be able to read, and what QUESTPIE will and will not promise about it.
Most jobs should carry an id and look the rest up. { userId: "usr_123" } is
fine in any broker, and the handler reads the row itself. Reach for this page
only when a value must cross the queue and cannot be resolved later. A one-time
link is the usual case.
Turning it on
Set secretPayload: true on the publish call. It needs an idempotencyKey.
const dispatchId = await queue.deliverOneTimeLink.publish(
{ recipientId, rawLink },
{
idempotencyKey: `one-time-link:${recipientId}:v1`,
secretPayload: true,
},
);publish() validates the plaintext, then generates a random AES-256-GCM data
key and encrypts the payload with it. Only ciphertext reaches the ledger and the
broker. The data key itself is wrapped with a key derived from your app secret
and stored on the dispatch row.
A worker unwraps that key only after it takes a leased execution claim on the
dispatchId. The claim fences concurrent deliveries and is renewed while the
handler runs. A returning handler records completion and erases the wrapped key.
A duplicate delivery after that is acknowledged without running your code again.
What it requires
publish() fails closed on the first three. The fourth is on you.
| Requirement | Why |
|---|---|
An idempotencyKey | The dispatch needs a durable row to hold the key. |
secret in runtimeConfig, 32 bytes or more | It derives the wrapping key. |
An adapter with executionTerminalState | Erasure needs durable proof the work is finished. |
A stable secret until the work is done | There is no key ring for old secrets in this release. |
Keep the idempotencyKey itself non-secret. It is stored in clear text and it
survives the payload.
Only pg-boss qualifies today. BullMQ and Cloudflare Queues do not expose every broker-owned terminal path, so they refuse rather than leave a key behind.
Rotating the app secret orphans in-flight secrets
The wrapping key is derived from runtimeConfig.secret. Change it while
secret-bearing work is still queued and that work can no longer be decrypted.
Reading a receipt
queue.getReceipt(dispatchId) projects the lifecycle without the payload.
const receipt = await queue.getReceipt(dispatchId);
// { dispatchId, jobName, idempotencyKey?, status, createdAt, queuedAt, handledAt }status | What it means |
|---|---|
queued | Accepted or still relaying. queuedAt is set only after the broker accepted. |
completed | The handler returned. Nothing more. |
failed | Publication ran out of recovery, or broker state no longer proves the handler finished. |
completed is not delivery. Take a handler whose last act is
email.sendTemplate(). completed there says the mail adapter accepted the
request. It says nothing about an inbox. Project it as "sent" only if that is
what the word means in your app.
The receipt carries no payload, no wrapped key, no adapter job id and no
provider error. getReceipt() returns null for an ordinary non-secret
dispatch, so it cannot become a general ledger-read API.
`getReceipt()` applies no authorization
It is a server-side infrastructure query. If a route projects a receipt to a user, that route has to authorize it.
What the boundary does not cover
QUESTPIE keeps its own errors, spans and queue log lines payload-free for a secret dispatch. That boundary stops at your code. A schema refinement, your handler, a provider SDK or your own logger can all record plaintext before anything throws.
So do not log the payload, and do not put it in application telemetry.
A failed receipt does not prove nothing happened. A provider may have accepted
the side effect in the crash gap. Pass dispatchId to that provider's
idempotency facility so a repeat resolves to the same operation.
Workers
Publishing a job only enqueues it. This is the entrypoint you write to drain the queue, in a long-running process, a serverless tick, or a pushed batch.
Sending
ctx.email is a MailerService with three methods. sendTemplate renders a registered template and sends it, send takes a raw message, and renderTemplate stops at the HTML.