diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 729aefe..a879385 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -93,6 +93,7 @@ export default withMermaid(defineConfig({
collapsed: false,
items: [
{ text: 'pg-sveltekit', link: '/repos/pg-sveltekit' },
+ { text: 'pg-node', link: '/repos/pg-node' },
{ text: 'pg-dotnet', link: '/repos/pg-dotnet' },
{ text: 'pg-manual', link: '/repos/pg-manual' },
],
diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md
index d4832a0..b3c6810 100644
--- a/docs/guide/architecture.md
+++ b/docs/guide/architecture.md
@@ -172,6 +172,7 @@ Both encryption and decryption support streaming (`ReadableStream`/`WritableStre
|---|---|---|
| `POST` | `/v2/request/start` | Start a Yivi identity verification session. Accepts attribute disclosure requirements. |
| `GET` | `/v2/request/jwt/{token}` | Retrieve the JWT result of a completed Yivi session. |
+| `GET` | `/v2/request/statusevents/{token}` | Stream session status as Server-Sent Events, so clients watch for completion over one persistent connection instead of polling. Unauthenticated: the token is the session token and the stream carries only status events, no key material. |
#### Key Issuance
diff --git a/docs/repos/overview.md b/docs/repos/overview.md
index 69f2b68..aaf466e 100644
--- a/docs/repos/overview.md
+++ b/docs/repos/overview.md
@@ -42,6 +42,7 @@ From the [postguard-examples](https://github.com/encryption4all/postguard-exampl
| Project | Language | Description |
|---|---|---|
| [pg-sveltekit](/repos/pg-sveltekit) | TypeScript | SvelteKit web app using `@e4a/pg-js` |
+| [pg-node](/repos/pg-node) | JavaScript | Node.js CLI using `@e4a/pg-js` from a server runtime |
| [pg-dotnet](/repos/pg-dotnet) | C# | .NET console app using `E4A.PostGuard` |
| [pg-manual](/repos/pg-manual) | JavaScript | Browser example using `@e4a/pg-wasm` directly (no SDK) |
diff --git a/docs/repos/pg-dotnet.md b/docs/repos/pg-dotnet.md
index b30c749..3559a2a 100644
--- a/docs/repos/pg-dotnet.md
+++ b/docs/repos/pg-dotnet.md
@@ -11,7 +11,7 @@ It shows two patterns:
## Prerequisites
-- .NET 8.0+ SDK
+- .NET 10.0+ SDK
- Rust toolchain via [rustup](https://rustup.rs/) (for building the native crypto library)
- A PostGuard API key
diff --git a/docs/repos/pg-node.md b/docs/repos/pg-node.md
new file mode 100644
index 0000000..33fa7ed
--- /dev/null
+++ b/docs/repos/pg-node.md
@@ -0,0 +1,79 @@
+# pg-node
+
+[GitHub](https://github.com/encryption4all/postguard-examples/tree/main/pg-node) · JavaScript · Node.js Example
+
+A plain Node.js CLI example showing how to use [`@e4a/pg-js`](/repos/postguard-js) from a server runtime. Part of the [postguard-examples](https://github.com/encryption4all/postguard-examples) repository.
+
+Mirrors the [pg-sveltekit](/repos/pg-sveltekit) "Informatierijk notificeren" flow (citizen exact-email recipient + organisation email-domain recipient) as a CLI script.
+
+It shows two modes:
+
+1. **Encrypt and Send**: Encrypts the input files for both recipients, uploads to Cryptify, and asks Cryptify to email each recipient a download link.
+2. **Encrypt and Upload**: Same upload, but silent. Cryptify returns a UUID you can distribute through some other channel.
+
+## Prerequisites
+
+- **Node.js 22+**, matching the example's `engines.node`. The SDK itself supports Node 20.3+, Bun, and Deno (see [postguard-js > Server-side usage](/repos/postguard-js#server-side-usage)).
+- A PostGuard for Business API key.
+
+## Setup
+
+```bash
+cd pg-node
+npm install
+cp .env.example .env
+# edit .env: set at minimum PG_API_KEY
+```
+
+Run one of the two modes:
+
+```bash
+npm run send # encrypt + upload + ask Cryptify to send mails
+npm run upload # encrypt + upload silently, no mails
+```
+
+The script prints the resulting `uuid` and the corresponding `/download?uuid=...` URL.
+
+## Configuration
+
+| Variable | Description | Default |
+| ----------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------- |
+| `PG_API_KEY` | PostGuard for Business API key (`PG-...`) | *(required)* |
+| `PG_PKG_URL` | PostGuard PKG server URL | `https://pkg.staging.postguard.eu` |
+| `PG_CRYPTIFY_URL` | Cryptify file-sharing URL | `https://storage.staging.postguard.eu` |
+| `PG_DOWNLOAD_URL` | PostGuard website used in `/download` URLs | `https://staging.postguard.eu` on staging Cryptify, else `https://postguard.eu` |
+| `PG_CITIZEN_EMAIL` | Citizen recipient (exact email match) | `citizen@example.com` |
+| `PG_ORGANISATION_EMAIL` | Organisation recipient (matches by domain) | `noreply@example.org` |
+| `PG_MESSAGE` | Optional unencrypted body for Cryptify's notify mail | *(empty)* |
+| `PG_INPUT_FILES` | Comma-separated file paths to encrypt | two in-memory demo files |
+
+The default `PG_CRYPTIFY_URL` is the staging deployment. Staging Cryptify does not actually deliver notification emails, so `npm run send` succeeds without spamming real inboxes while you integrate. The upload still returns a real UUID and the download URL is usable.
+
+## How it maps to the SDK
+
+The encryption code lives in `src/encryption.mjs`. The send mode passes a `notify` object to opt into Cryptify-sent emails:
+
+```js
+const sealed = pg.encrypt({
+ files,
+ recipients: [
+ pg.recipient.email(citizen.email),
+ pg.recipient.emailDomain(organisation.email),
+ ],
+ sign: pg.sign.apiKey(apiKey),
+ onProgress,
+ signal,
+});
+
+const result = await sealed.upload({
+ notify: {
+ recipients: true,
+ message: message || undefined,
+ language: 'EN',
+ },
+});
+```
+
+[Source: encryption.mjs#L13-L40](https://github.com/encryption4all/postguard-examples/blob/0fb7789560595d29d28fcf4222e67dc1ab887c2e/pg-node/src/encryption.mjs#L13-L40)
+
+The upload mode calls `sealed.upload()` with no options. The upload is silent by default; pass `notify` only when you want Cryptify to send the recipient mail. See [JS SDK > Notify options](/sdk/js-encryption#notify-options) for the full shape.
diff --git a/docs/repos/postguard-dotnet.md b/docs/repos/postguard-dotnet.md
index 2685c56..531a18f 100644
--- a/docs/repos/postguard-dotnet.md
+++ b/docs/repos/postguard-dotnet.md
@@ -51,6 +51,14 @@ var result = await sealed.UploadAsync(new UploadOptions
byte[] bytes = await sealed.ToBytesAsync();
```
+### Client version header
+
+The SDK sends an `X-POSTGUARD-CLIENT-VERSION` header on every PKG and Cryptify request so the servers can attribute traffic by SDK and version. The value has four comma-separated parts: `dotnet,,pg-dotnet,`, where `` comes from `RuntimeInformation.FrameworkDescription` and `` from the assembly version.
+
+The header is injected once on the SDK-owned `HttpClient`. A caller-supplied header (any casing) wins. If you bring your own `HttpClient`, the SDK does not mutate it, so you own its headers in that case.
+
+Source: [encryption4all/postguard-dotnet#33](https://github.com/encryption4all/postguard-dotnet/pull/33).
+
## Architecture
```
@@ -71,7 +79,7 @@ The SDK calls into the Rust `pg-ffi` native library for all cryptographic operat
### Prerequisites
-- .NET 8.0+ SDK
+- .NET 10.0+ SDK
- Rust toolchain (for building the native library)
### Build the native library
diff --git a/docs/repos/postguard-js.md b/docs/repos/postguard-js.md
index fb5c522..8d707af 100644
--- a/docs/repos/postguard-js.md
+++ b/docs/repos/postguard-js.md
@@ -32,6 +32,14 @@ const pg = new PostGuard({
});
```
+### Client version header
+
+The SDK stamps an `X-POSTGUARD-CLIENT-VERSION` header onto every PKG and Cryptify request so the servers can attribute traffic by SDK and version. The value has four comma-separated parts: `host,host_version,pg-js,`, where `host` is the detected runtime (`browser`, `node`, `bun`, or `deno`).
+
+You do not need to set it. If you pass your own `X-POSTGUARD-CLIENT-VERSION` in `headers` (any casing), it wins. Embedding hosts use this to report their own identity, for example the Outlook add-in reports `pg4ol`.
+
+Source: [encryption4all/postguard-js#90](https://github.com/encryption4all/postguard-js/pull/90).
+
## Architecture
The SDK uses a lazy builder pattern. `pg.encrypt()` and `pg.open()` return builder objects that capture parameters but do no work. The actual operation runs when you call a terminal method.
@@ -262,11 +270,19 @@ try {
}
```
+## Server-side usage
+
+The SDK runs on browsers, Node.js (20.3+), Bun, and Deno. The lower bound is set by `AbortSignal.any`, listed in `engines.node` of the package.
+
+Encrypt and upload calls work identically across all four runtimes. Decryption with `pg.sign.yivi(...)` and `opened.decrypt({ element })` is browser-only — both render the Yivi QR widget into a DOM element. Calling `pg.sign.yivi(...)` from a server runtime throws `YiviSessionError` upfront before any session starts; pick `pg.sign.apiKey(...)` or `pg.sign.session(...)` instead.
+
+See the [pg-node example](/repos/pg-node) for a runnable Node.js script using `pg.sign.apiKey`.
+
## Development
### Prerequisites
-- Node.js 20+
+- Node.js 20.3+ (or Bun / Deno)
### Building
diff --git a/docs/repos/postguard-outlook-addon.md b/docs/repos/postguard-outlook-addon.md
index da51464..2581d8a 100644
--- a/docs/repos/postguard-outlook-addon.md
+++ b/docs/repos/postguard-outlook-addon.md
@@ -13,8 +13,9 @@ The add-in runs inside Outlook's web add-in framework. It uses the Office JavaSc
The Outlook add-in uses Office JS APIs instead of WebExtension APIs:
- Manifest: XML-based (`manifest.xml`) instead of `manifest.json`.
-- Taskpane: read-mode decryption UI (`src/taskpane/read-view.ts`) and compose-mode policy editor (`src/taskpane/compose-view.ts`, `src/taskpane/policy-editor.ts`). The taskpane shell (`src/taskpane/taskpane.ts`) routes between views.
+- Taskpane: read-mode decryption UI (`src/taskpane/read-view.ts`) and compose-mode policy editor (`src/taskpane/compose-view.ts`, `src/taskpane/policy-editor.ts`). The taskpane shell (`src/taskpane/taskpane.ts`) routes between views. After a successful decrypt, the read view opens the decrypted message in the read dialog (below); it only renders the message inline in the taskpane as a fallback when the dialog cannot open.
- Yivi dialog: a separate page (`src/yivi-dialog/yivi-dialog.{ts,html}`) hosted at `yivi-dialog.html`. It runs pg-js plus the Yivi QR widget in its own WebView2 window so encryption can happen during the Send pipeline, where the taskpane is not available.
+- Read dialog: a separate page (`src/read-dialog/read-dialog.{ts,html}`, manifest entry `ReadDialog.Url`) that shows the decrypted email in a larger popup window with a normal email layout (header fields, badges, body, attachments). The taskpane opens it with `displayDialogAsync` after decryption and passes the already-decrypted content in memory over the same `messageChild` / `messageParent` chunking protocol as the Yivi dialog — nothing is written to disk. The body is rendered through the shared `wrapHtml()` helper into the same sandboxed (`allow-same-origin`, no `allow-scripts`) iframe the taskpane uses. The taskpane keeps the plaintext on in-memory state, so a "Show decrypted message" button can re-open the window without a second Yivi/decrypt round-trip.
- Launchevent runtime: `src/launchevent/launchevent.ts` registers two events. `OnNewMessageCompose` fires when a new compose, reply, or forward opens — it seeds the per-draft `x-pg-encrypt-on-send` header from the mailbox-wide default and paints the persistent in-message banner. `OnMessageSend` reads only that header to decide whether to open the Yivi dialog with `displayDialogAsync`, write the encrypted result back into the outgoing item, and release Send. On `Office.context.platform === Office.PlatformType.Mac` the send handler exits early with a Smart Alert pointing the user at the taskpane "Encrypt & Send" button; `displayDialogAsync` from a launchevent runtime is broken on Outlook for Mac native (OfficeDev/office-js #3138, #3085, #5681).
- Settings view: `src/taskpane/settings-view.ts` (taskpane gear icon, top-right). Exposes the mailbox-wide encryption default, the optimistic-dialog opt-in, and Yivi sign-attribute prefills. All values are written to `roamingSettings` so the launchevent runtime can read them too.
- Shared helpers under `src/lib/`: `office-helpers.ts` (Office.js wrappers including the notification banner helpers), `settings.ts` (typed roaming-settings keys shared by taskpane and launchevent), `mime.ts` (MIME assembly and parsing), `graph-client.ts` (Graph API for fetching the full sent item), `pkg-client.ts` (PKG endpoints and host config), `auth.ts` (PKG bearer JWT exchange), `i18n.ts`, `encoding.ts`, `attributes.ts`, `storage.ts`, `types.ts`, and `dialog-chunk.ts` (chunked `messageChild` / `messageParent` protocol).
@@ -108,7 +109,7 @@ The dispatch loop drives the message protocol:
[Source: launchevent.ts#L340-L356](https://github.com/encryption4all/postguard-outlook-addon/blob/2fcc56ec4fc7ec34bb557a4d5de2b3d317d636fa/src/launchevent/launchevent.ts#L340-L356)
-The taskpane Yivi flow (compose-mode policy signing, read-mode decryption) is different: there the Yivi QR widget runs inline in the taskpane DOM at `#yivi-web-form` rather than in a popup, because the taskpane is already a long-lived web context.
+The taskpane Yivi flow (compose-mode policy signing, read-mode decryption) is different: there the Yivi QR widget runs inline in the taskpane DOM at `#yivi-web-form` rather than in a popup, because the taskpane is already a long-lived web context. In read mode, once that inline Yivi step and the decrypt finish, the plaintext message itself is handed off to the read dialog (above) rather than shown in the taskpane.
The pg-js SDK inlines its WASM as base64 at build time, so no separate WASM loader is needed.
diff --git a/docs/repos/postguard.md b/docs/repos/postguard.md
index e36a0f6..e5fd906 100644
--- a/docs/repos/postguard.md
+++ b/docs/repos/postguard.md
@@ -211,6 +211,7 @@ The PKG server (`pg-pkg`) exposes an HTTP API. By default it listens on `http://
|---|---|---|
| `POST` | `/v2/irma/start` | Start a Yivi identity verification session. |
| `GET` | `/v2/irma/jwt/{token}` | Retrieve the JWT result of a completed Yivi session. |
+| `GET` | `/v2/irma/statusevents/{token}` | Stream Yivi session status as Server-Sent Events, so clients watch for completion over one connection instead of polling. Unauthenticated: the token is the session token and the stream carries only status events, no key material. |
Start a Yivi session with a request body like:
diff --git a/docs/sdk/dotnet-errors.md b/docs/sdk/dotnet-errors.md
index 9030405..c741470 100644
--- a/docs/sdk/dotnet-errors.md
+++ b/docs/sdk/dotnet-errors.md
@@ -33,14 +33,17 @@ Thrown when an HTTP request to the PKG or Cryptify fails.
|---|---|---|
| `StatusCode` | `int` | HTTP status code |
| `Body` | `string` | Response body |
+| `Url` | `string` | Request URL that failed (PKG or Cryptify endpoint) |
```csharp
catch (NetworkException ex)
{
- Console.WriteLine($"HTTP {ex.StatusCode}: {ex.Body}");
+ Console.WriteLine($"HTTP {ex.StatusCode} at {ex.Url}: {ex.Body}");
}
```
+The exception message follows the same format: `HTTP {status} at {url}: {body}` (e.g. `HTTP 401 at https://pkg.postguard.eu/v2/irma/sign/key: Unauthorized`). The `Url` property lets callers wrapping this SDK distinguish a PKG failure from a Cryptify failure (and which Cryptify phase) without parsing the message.
+
## `SealException`
Thrown when the native cryptographic library (`libpg_ffi`) fails during encryption. This typically indicates a problem with the input data or the native library itself.
diff --git a/docs/sdk/js-email-helpers.md b/docs/sdk/js-email-helpers.md
index 17ce995..3337b9b 100644
--- a/docs/sdk/js-email-helpers.md
+++ b/docs/sdk/js-email-helpers.md
@@ -148,7 +148,7 @@ Callers that handle the result must null-check `envelope.attachment` before read
|-----------|------|----------|-------------|
| `sealed` | `Sealed` | Yes | The Sealed encryption builder from `pg.encrypt()` |
| `from` | `string` | Yes | Sender email address |
-| `websiteUrl` | `string` | No | URL to link in the placeholder body (default: `https://postguard.eu`) |
+| `websiteUrl` | `string` | No | URL to link in the placeholder body (default: `https://postguard.eu`). Must be an absolute `https:` URL. `createEnvelope` validates it, strips any trailing slash, and throws on a malformed or non-https value. |
| `unencryptedMessage` | `string` | No | Unencrypted message shown in the placeholder |
| `senderAttributes` | `string[]` | No | Verified sender attributes to display below the sender name |
| `uploadToCryptify` | `boolean` | No | Default `true`. Set `false` to keep tier 2 envelopes as a local attachment only and skip the Cryptify upload + body link. Has no effect on tier 1 (no upload happens) or tier 3 (upload is always attempted because there is no fallback). |
diff --git a/docs/sdk/js-encryption.md b/docs/sdk/js-encryption.md
index 157369b..5d88435 100644
--- a/docs/sdk/js-encryption.md
+++ b/docs/sdk/js-encryption.md
@@ -220,6 +220,10 @@ The upload is silent by default. Both recipient and sender mails are opt-in. Pas
| `message` | `string` | `undefined` | Optional unencrypted text included in any mail sent |
| `language` | `'EN' \| 'NL'` | `'EN'` | Notification email template language |
+The SDK validates the `notify` shape and throws `TypeError` for common misuse like `{ notify: true }`, a top-level `recipients`, or non-boolean values such as `{ recipients: 'yes' }`. Catch this in tests rather than at runtime.
+
+If `notify` is omitted on the first `sealed.upload()` for a given `PostGuard` instance, the SDK logs a one-time `console.info` reminding you that the upload is silent and how to opt in. Pass `{ recipients: false }` to acknowledge the silent intent and suppress the notice — the validator counts both as explicit shapes.
+
## Encrypt raw data
For email addons, use `data` instead of `files`. The Thunderbird addon's crypto popup encrypts the full MIME message (body + attachments) as raw bytes, then wraps it in an email envelope:
diff --git a/docs/sdk/js-errors.md b/docs/sdk/js-errors.md
index 7705fc5..fa4c4e7 100644
--- a/docs/sdk/js-errors.md
+++ b/docs/sdk/js-errors.md
@@ -125,6 +125,7 @@ Thrown when:
- The user declines disclosure in the Yivi app
- The Yivi session times out
- The Yivi session is aborted for any other non-success reason
+- `pg.sign.yivi(...)` is called from a non-browser runtime (Node, Bun, Deno). The QR widget needs a DOM, so the call fails fast before any session starts. Use `pg.sign.apiKey(...)` or `pg.sign.session(...)` from a server runtime.
Affects both `pg.encrypt(...).upload()` and `pg.encrypt(...).toBytes()` when `pg.sign.yivi(...)` is used, and `pg.open(...).decrypt(...)` when a Yivi session backs the decryption.