/v1/messages/{id}/content
Decrypts and returns the retained body — subject, html, text, variables. Viewing content is a distinct, audit-logged act, never a side effect of listing messages.
Authentication
Requires messages:read-content. A messages:read key without it gets 403.
Keys rule — prefix, shown once, abilities never widen, and no key carries both messages:send and messages:read-content.
A sealed body is owner-only and audited. Revealing one needs an owner's key. Any other key is refused with type: sealed_body_denied — the reveal_sealed_body it names is an ability of the owner role, which no key can carry. Have an owner mint a read-content key, or open the message in the panel as an owner.
Worked example
PHP
Node
Python
Ruby
{
"subject": "Reset password",
"html": "<p>Reset <a href=\"https://basalt.app/reset/tk_9f2a81\">link</a> (30m)</p>",
"text": "Reset https://basalt.app/reset/tk_9f2a81 (30m)",
"variables": {
"reset_url": "https://basalt.app/reset/tk_9f2a81",
"expires_minutes": 30
},
"expires_at": null
}Responses & failures
Getting started says zero is right for credential mail. While the message is still queued, the body is the send buffer and content_expires_at is null, so this endpoint can still return it. When the retention in force is 0, reaching a final state sets the expiry to that completion time. A later read is 410, message Content expired — the same response as a body kept for some days and then aged out. The days are the template's content_retention_days when that template sets one, otherwise the account's, capped by the plan.
{
"message": "Content expired",
"completed_at": null
}Ciphertext present but undecryptable returns 503 with type: body_unreadable — never an empty successful body.
Each read that passes the cap is recorded for the person who minted the key, from midnight in the account's timezone. Opens in the panel count toward the same cap. The count is the audit action message.content_viewed. The cap is the lower of the plan's content_views_per_day and that person's own limit, and a value that is unset is ignored. With neither set, the read is not limited. Past the cap the response is 429, type content_view_quota_exceeded, with limit and used, and that refusal is not counted as a view. A key with no holder shares one bucket. The message starts "Daily content-view limit reached" and names the reset as midnight in the account timezone.
Audit
A read that passes the cap writes an audit event message.content_viewed with the API key id, before the body is decrypted, so a body that cannot be read still counts. A refusal for the cap does not. That is why this endpoint is separate from list and show.