# Secret payloads (/docs/code/jobs/secret-payloads)

---
title: Secret payloads
description: 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.
kind: guide
package: questpie
---

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`.

```ts
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.

<Callout type="warn" title="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.
</Callout>

## Reading a receipt

`queue.getReceipt(dispatchId)` projects the lifecycle without the payload.

```ts
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.

<Callout type="warn" title="`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.
</Callout>

## 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.
