Receipts
Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression — process-local by default.
Every Channel send records a receipt — success (sent / fallback), suppression, or a
classified failure. Provider bounces and complaints update that ledger through normalized
outcomes. There is no oke_receipts SQL table and no fx.channel.getReceipt helper.
For operators watching deliverability — Console projects the ledger; Flows only see send
results via fx.send’s { ok: true } gate.
The one rule
Consent and prior hard bounces suppress before any driver runs. Default suppression / consent / receipts stores are process-local memory — inject shared stores for multi-instance, or run a single Channel consumer, until a durable driver ships.
Smallest Example
Send and accept the receipt
const result = await fx.send(noteCreatedMail, {
to: "alice@example.com",
data: { id: "n1", title: "Hi" },
});
// result.ok === true on the fx gate when the capability succeeds;
// the runtime ledger still stores status, attempts, and messageId.Dry-run never contacts a provider and still records would have fired.
Understand statuses
| Status | Meaning |
|---|---|
sent | First (or only) attempt succeeded |
fallback | An earlier attempt failed; a later same-medium driver succeeded |
suppressed/opted-out | Consent store blocked the address |
suppressed/prior-bounce | Prior hard bounce on the suppression list |
failed / opted-out | Legacy aliases kept for older callers |
Watch the boot warning
oke boot: Channel suppression/consent/receipts default to process-local memory —
opt-out, bounce, and receipt state on one instance is invisible to others.
Inject shared stores for multi-instance, or run a single Channel consumer, until a
durable driver ships.Progressive Patterns
Opt-out or prior bounce never hits SMTP / SMS:
// Runtime path (not on fx): consent.optOut(subject, medium | "all")
// Next fx.send → receipt status suppressed/opted-out, driverId "suppression", ok: falseErrors on the receipt: "opted out" or "prior hard bounce".
Receipt shape
| Field | Meaning |
|---|---|
id | Runtime receipt id |
template | Template name |
to | Recipient |
medium | email · sms · whatsapp · push · … |
locale / localeChain | Resolved locale + chain steps |
status | Success, legacy, or outcome state |
messageId | Provider / runtime message id |
driverId | Winning driver (or suppression) |
attempts | Every try (driverId, ok, error, at) |
at | Epoch ms |
error | Aggregated attempt errors when failed |
Consent & suppression
| Store | Role |
|---|---|
| Consent | isOptedOut / optOut / optIn / list per subject + medium |
| Suppression | Reasons "opted-out" | "prior-bounce"; checked on every send |
Consequence: suppression is not failure — verdict correct for opted-out and prior-bounce
rows. Complaints (delivered-then-complained) outrank many hard bounces in consequence weight.
There is no Flow helper to query receipts today — use Console projection or an injected ledger from operator tooling.
Troubleshooting
Expected on single-node oke dev. For replicas, inject shared suppression / consent /
receipts or pin Channel work to one consumer.
Check receipt status — suppressed/* never calls a driver. Opt-in the address or clear prior
bounce on the suppression store.
Same-medium via (or ordered driver chain) recovered after a provider error. Inspect attempts[]
for which driver failed and which succeeded.
That API does not exist. Use the runtime receipts store (injected or Console), not a Flow
method. fx.send only returns { ok: true } after the capability gate.
ingestOutcome with hard-bounce must run (webhook → normalized outcome). Without ingestion,
suppression never learns the bounce.
Learn more
- Channel overview — send path and
ChannelPhysics - Email — SMTP / provider drivers
- fx — dry-run send behavior