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. In Railway DEV, values reused by multiple services belong in project real-estate-core → environment development Shared Variables and must be referenced only by their consuming services; service-specific values remain service variables.
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 Railway's development Shared ││ Variables and reference it only from web and apps/api. ││ Restart those services. ││ ││ 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.