QUESTPIE
CodeJobs

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.

View markdown

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.

RequirementWhy
An idempotencyKeyThe dispatch needs a durable row to hold the key.
secret in runtimeConfig, 32 bytes or moreIt derives the wrapping key.
An adapter with executionTerminalStateErasure needs durable proof the work is finished.
A stable secret until the work is doneThere 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 }
statusWhat it means
queuedAccepted or still relaying. queuedAt is set only after the broker accepted.
completedThe handler returned. Nothing more.
failedPublication 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.

On this page