Notifications configuration
Configuration is split across two surfaces: environment variables (read by code at runtime / build time) and the Knock dashboard (workflow definitions, channel provider config, identification policy). This page documents both and the contract between them.
Environment variables
Section titled “Environment variables”Grouped by surface — same convention as Settings → Environment variables.
Server-only (svelte-web, apps/api, worker)
Section titled “Server-only (svelte-web, apps/api, worker)”| Variable | Purpose |
|---|---|
KNOCK_SECRET_API_KEY | Knock Node SDK auth. Used by packages/server-services/src/integrations/knock.ts and apps/worker/src/knock.ts. |
KNOCK_SIGNING_KEY | Signs userToken for the client SDK. Required once “Require identification token verification” is ON in the Knock dashboard. Get from Knock dashboard → Settings → Authentication → Signing Keys. |
KNOCK_FCM_CHANNEL_ID | Knock channel ID for Android FCM push. Mobile clients ship their FCM token to notifications.registerDevice, server calls knock.users.setChannelData(channelId=KNOCK_FCM_CHANNEL_ID, { tokens }). |
KNOCK_APNS_CHANNEL_ID | Knock channel ID for the agent iOS app’s APNs push. |
KNOCK_APNS_USER_CHANNEL_ID | Knock channel ID for the customer iOS app’s APNs push (separate bundle id / .p8 from the agent app). registerDevice’s app: 'user' routes customer tokens here; setPushDeviceToken no-ops until it’s set. |
KNOCK_SERVICE_TOKEN | CLI/CI only — not read by the running app. Authenticates the Knock CLI for knock pull / knock push against the knock/ source of truth. .env.local locally; GitHub secret in CI. Generate at Knock dashboard → Settings → Service tokens. See Workflows as code. |
Web client (exposed to the browser)
Section titled “Web client (exposed to the browser)”| Variable | Purpose |
|---|---|
PUBLIC_KNOCK_API_KEY | Public client SDK key. Anyone with the bundle can read this — that’s by design. Security relies on signed userToken. |
PUBLIC_KNOCK_FEED_CHANNEL_ID | Knock in-app feed channel UUID. |
The native mobile apps wire their Knock client (in-app feed) in a later phase — see Conventions → Mobile (Native). They will read the same public key + feed channel id, not an EXPO_PUBLIC_* variant.
Email channel provider (Resend)
Section titled “Email channel provider (Resend)”| Variable | Purpose |
|---|---|
RESEND_API_KEY | Knock dashboard’s email channel uses Resend as the SMTP provider. The API key is configured Knock-side, not in our code, but the same Resend account is used for non-Knock emails (e.g. OTP) too. |
RESEND_FROM_EMAIL | Sender. Knock’s email channel is configured with the same address. |
Knock dashboard checklist
Section titled “Knock dashboard checklist”Run through this on each Knock environment (dev / staging / prod) at least once. The signed-token requirement is the most important.
| # | Setting | Required value |
|---|---|---|
| 1 | Settings → Authentication → Require identification token verification | ON (production). Without this, the public API key alone authenticates any user id — anyone with the bundle can read any user’s feed. |
| 2 | Settings → Authentication → Signing Keys | At least one active key. Mirror to KNOCK_SIGNING_KEY server env. |
| 3 | Channels → Email → Provider | Resend, with RESEND_API_KEY configured in the dashboard. |
| 4 | Channels → Email → HTML escaping | OFF (we escape server-side via escapeHtml from @repo/notifications). |
| 5 | Channels → FCM (Android) | Provisioned. Channel ID mirrored to KNOCK_FCM_CHANNEL_ID. |
| 6 | Channels → APNs (iOS) | Provision the APNs channel, mirror its id to KNOCK_APNS_CHANNEL_ID. iOS devices already register their APNs token unconditionally, so push starts delivering as soon as the channel id is set — no client change. |
| 7 | Channels → In-app feed | Provisioned. Feed ID mirrored to PUBLIC_KNOCK_FEED_CHANNEL_ID. |
| 8 | Workflows | All 14 active keys defined (see Overview for the list). Trigger step → in-app step → email step. Each step renders {{ inAppBody }} / {{ emailSubject }} / {{ emailBody }} from the payload. Now codified in knock/ — see Workflows as code; the dashboard is the runtime, not the source of truth. |
| 9 | Webhooks → Delivery events | Pointed at the platform’s webhook endpoint (/api/knock-webhook or /api/knock/webhook per apps/api). Used for KnockDeliveryEvent ingestion — see plan Workstream B. |
Channel mapping
Section titled “Channel mapping”| Channel kind | Source of channel ID | Used by |
|---|---|---|
| In-app feed | PUBLIC_KNOCK_FEED_CHANNEL_ID | Web knock-client.svelte.ts; native mobile feed (later phase) |
| Email (Resend) | Dashboard-managed; no code-side ID | Knock automatically routes when email channel is enabled on the workflow |
| Push (FCM) | KNOCK_FCM_CHANNEL_ID | Server setChannelData after notifications.registerDevice (shared by both Android apps) |
| Push (APNs — agent) | KNOCK_APNS_CHANNEL_ID | registerDevice with app: 'agent' (agent apps) |
| Push (APNs — customer) | KNOCK_APNS_USER_CHANNEL_ID | registerDevice with app: 'user' (customer apps); setPushDeviceToken selects the channel by the app field. Delivery starts once the channel id is set |
Channel IDs are environment-scoped on the Knock side — dev / staging / prod each have their own. The repo’s .env.example documents the variables, the actual values per environment live in the Dokploy env config.
Workflows as code (Knock CLI)
Section titled “Workflows as code (Knock CLI)”Workflows + the email layout are version-controlled under knock/ at the repo root and are the source of truth — the Knock dashboard is the runtime that this gets pushed to. Managed with the Knock CLI (@knocklabs/cli, a root devDependency); knock.json points it at ./knock.
What is codified: workflows (thin — they route the pre-rendered payload) and the passthrough email layout. What is NOT: channel/provider configs (in-app feed, Resend, FCM, APNs) — the CLI can’t manage those, so they stay dashboard-only (checklist items 3–7 above). Translations + email HTML also stay code-owned in @repo/notifications — they are not pushed to Knock’s translation/layout system.
Authenticate the CLI with KNOCK_SERVICE_TOKEN (CLI/CI only). Common commands, run from the repo root:
bunx knock whoami --service-token "$KNOCK_SERVICE_TOKEN" # verify authbunx knock pull --service-token "$KNOCK_SERVICE_TOKEN" # dashboard → knock/bunx knock push --commit -m "msg" --service-token "$KNOCK_SERVICE_TOKEN" # knock/ → Development + commitbunx knock commit promote --only=<commit-id> --force --service-token "$KNOCK_SERVICE_TOKEN" # one commit → next environmentpush is Development-only; later environments are reached with commit promote. There is no --dry-run on push — to check fidelity after a pull, re-run pull and confirm git diff is empty (the pull is idempotent), then inspect the JSON.
CI: .github/workflows/knock.yml validates, pushes, and commits to Development on every push to development that touches knock/. Production promotion is manual: dispatch the workflow with the exact Knock commit ID to promote, so unrelated pending commits are never swept into a release.
Signing-key rotation
Section titled “Signing-key rotation”┌──────────────────────────────────────────────────────────────────┐│ 1. Knock dashboard → Settings → Authentication → Signing Keys ││ → "Add new signing key" — Knock generates a fresh key. ││ Keep the old key active during the overlap window. ││ ││ 2. Update KNOCK_SIGNING_KEY in Dokploy (dev; prod once it ││ exists). Web + apps/api + worker restart. ││ ││ 3. Wait for all in-flight signed tokens issued under the old ││ key to expire (1h after the cutover — token TTL is set in ││ `signKnockUserToken`). ││ ││ 4. Knock dashboard → revoke the old signing key. │└──────────────────────────────────────────────────────────────────┘If a signing key is suspected leaked, revoke it immediately at step 4 without waiting for token expiry — every signed token under that key is now invalid. Users will see a feed reload at next page nav as the client re-fetches a token.
Mobile push registration flow
Section titled “Mobile push registration flow”sequenceDiagram
participant App as Mobile app
participant OS as Device OS
participant API as apps/api (tRPC)
participant Server as @repo/server-services
participant Knock
App->>OS: request notification permission
OS-->>App: granted
App->>OS: fetch device push token (native FCM/APNs SDK)
OS-->>App: FCM token
App->>API: trpc.notifications.registerDevice({ token, platform, app })
API->>Server: ctx.services.knock.setPushDeviceToken(userId, platform, token, app)
Server->>Knock: knock.users.setChannelData(userId, channelId, { data: { tokens: [token] } })
Knock-->>Server: ok
Server-->>API: ok
API-->>App: ok
The iOS path is identical except the device token is an APNs token, and the server picks the APNs channel by the app field: app: 'agent' → KNOCK_APNS_CHANNEL_ID, app: 'user' → KNOCK_APNS_USER_CHANNEL_ID (Android always uses KNOCK_FCM_CHANNEL_ID). The agent apps send app: 'agent'; the customer apps send app: 'user'. The field is optional and defaults to 'agent' server-side, so un-updated binaries keep working.
See Mobile push for the deep-link routing table, sign-out unregistration, and per-platform troubleshooting (page added in a follow-up PR).
Local dev
Section titled “Local dev”For full Knock testing in local dev:
- Use a Knock dev environment (create one in the Knock dashboard separate from staging / prod).
- Set all the env vars above in
apps/svelte-web/.env.local,apps/api/.env.local, andapps/worker/.env.local. - For Knock webhook testing (Workstream B), expose your local web server via
ngrokand point the dashboard webhook at the public URL.
Cookies + signed tokens work identically in dev — no special bypass.