> For the complete documentation index, see [llms.txt](https://codemash.gitbook.io/codemash/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://codemash.gitbook.io/codemash/cloud/concepts/message-templates/preview-link.md).

# Preview link

Open a sent push, email or SMS with its signed preview link — no sign-in, 30 days.

## What the preview link opens

Every push, email and SMS that Norbix sends gets its own **preview link**. The link opens a page with the content of **that one message**, as the recipient got it — tokens already filled in:

| channel | what the preview shows    |
| ------- | ------------------------- |
| Push    | title and body            |
| Email   | subject and the HTML body |
| SMS     | the text                  |

The preview never shows the recipients, device tokens, provider settings or any other message of the campaign.

For email, the link is what the **View in web** block of a template points at: `https://<your cloud address>/notifications/emails/preview?hash=<hash>`. For push and SMS, the hash is stored on the message (`Campaign.NotificationHash` in the message model) and read with `GET /v3/notifications/push/preview?hash=<hash>` or `GET /v3/notifications/sms/preview?hash=<hash>` (email: `GET /v3/notifications/email/preview?hash=<hash>`).

## No sign-in needed

The link needs **no sign-in and no API key**. The `hash` in the link is the key: it is signed by Norbix, so it cannot be changed or made up, and it opens only the message it was made for.

**Anyone who has the link can see the content.** Treat it like the message itself: do not put a preview link where the message would not be allowed.

Members of the project can also open a preview **by id** when signed in: `GET /v3/notifications/push/preview?projectId=<id>&notificationId=<id>` needs the `push:read` permission (email: `email:read`, SMS: `sms:read`).

## What the link contains

The `hash` has two parts, `<part 1>.<part 2>`, both URL-safe base64:

| part | content                                                                                                                                                          | readable by anyone with the link?                                                                                   |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 1    | `<key version>:<encrypted payload>` — the payload is JSON with the project id, notification id, environment and expiry time, encrypted with the server's AES key | only the **key version** (for example `v2`). The project id, notification id, environment and expiry are encrypted. |
| 2    | HMAC-SHA256 signature of part 1                                                                                                                                  | no — it only proves part 1 was not changed                                                                          |

The signature key is derived (HKDF-SHA256) from the server secret `NORBIX_GATEWAY_AUTH_JWT_AESKEYBASE64` with the purpose label `notification-preview-link-v1`, so it is different from every other key. A link also opens only when it is exactly the link stored on that message.

Self-hosted installations: the AES encryption keys ship inside the Norbix binaries, so someone who has the binaries can read the payload of a link (project id, notification id, environment, expiry). They still cannot open a message without its exact link. Set `NORBIX_GATEWAY_AUTH_JWT_AESKEYBASE64` on every host (Hub, Api and the workers) to the same value. Without it, a host that is not a Development host sends messages **without** a preview link (the "View in web" block is left out) and refuses every preview link with 401; it prints one `[ERROR] … is not set` line with the reason. Only a host whose `ASPNETCORE_ENVIRONMENT` is explicitly `Development` (and that is not a release edition build) falls back to a built-in key, so a local checkout works without the secret. An unset `ASPNETCORE_ENVIRONMENT` counts as Production.

The link answer is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`, and the "View in web" page tells the browser not to send the link on to other sites.

The "View in web" page shows the email inside a sandboxed frame: nothing in the email can run scripts or reach the page. Links in the email open in a new tab, as they would from a mail client.

## It expires after 30 days

A preview link works for **30 days after the message was sent**. After that it answers "This preview link has expired." Links made before preview links were signed (September 2026) no longer open; they answer the same way.

## Error codes

| HTTP | error code                            | when                                                                                                                                                                                     |
| ---- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 401  | `CM-ERRORS-NOTIFICATIONS-PREVIEW-001` | the link is not valid: it was changed, it is for another message or project, or the message is gone. The answer is the same in every case, so it does not tell whether a message exists. |
| 401  | `CM-ERRORS-NOTIFICATIONS-PREVIEW-002` | the link has expired (30 days after sending).                                                                                                                                            |
| 401  | `CM-ERRORS-NOTIFICATIONS-PREVIEW-003` | no link and not signed in.                                                                                                                                                               |
| 400  | `CM-ERRORS-SECRETS-011`               | the `hash` is not a preview link at all.                                                                                                                                                 |
| 400  | `CM-ERRORS-SECRETS-001`               | the `hash` is empty.                                                                                                                                                                     |
| 403  | `CM-ERRORS-MEMBERSHIP-039`            | signed in, asked by id, but without `push:read` / `email:read` / `sms:read`.                                                                                                             |
| 404  | `CM-ERRORS-NOTIFICATIONS-INBOX-001`   | signed in with the permission, asked by an id that does not exist.                                                                                                                       |
| 429  | —                                     | too many preview requests from one address (60 per minute).                                                                                                                              |
