Skip to content

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.

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)”
VariablePurpose
KNOCK_SECRET_API_KEYKnock Node SDK auth. Used by packages/server-services/src/integrations/knock.ts and apps/worker/src/knock.ts.
KNOCK_SIGNING_KEYSigns 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_IDKnock 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_IDKnock channel ID for the agent iOS app’s APNs push.
KNOCK_APNS_USER_CHANNEL_IDKnock 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_TOKENCLI/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.
VariablePurpose
PUBLIC_KNOCK_API_KEYPublic client SDK key. Anyone with the bundle can read this — that’s by design. Security relies on signed userToken.
PUBLIC_KNOCK_FEED_CHANNEL_IDKnock 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.

VariablePurpose
RESEND_API_KEYKnock 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_EMAILSender. Knock’s email channel is configured with the same address.

Run through this on each Knock environment (dev / staging / prod) at least once. The signed-token requirement is the most important.

#SettingRequired value
1Settings → Authentication → Require identification token verificationON (production). Without this, the public API key alone authenticates any user id — anyone with the bundle can read any user’s feed.
2Settings → Authentication → Signing KeysAt least one active key. Mirror to KNOCK_SIGNING_KEY server env.
3Channels → Email → ProviderResend, with RESEND_API_KEY configured in the dashboard.
4Channels → Email → HTML escapingOFF (we escape server-side via escapeHtml from @repo/notifications).
5Channels → FCM (Android)Provisioned. Channel ID mirrored to KNOCK_FCM_CHANNEL_ID.
6Channels → 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.
7Channels → In-app feedProvisioned. Feed ID mirrored to PUBLIC_KNOCK_FEED_CHANNEL_ID.
8WorkflowsAll 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.
9Webhooks → Delivery eventsPointed 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 kindSource of channel IDUsed by
In-app feedPUBLIC_KNOCK_FEED_CHANNEL_IDWeb knock-client.svelte.ts; native mobile feed (later phase)
Email (Resend)Dashboard-managed; no code-side IDKnock automatically routes when email channel is enabled on the workflow
Push (FCM)KNOCK_FCM_CHANNEL_IDServer setChannelData after notifications.registerDevice (shared by both Android apps)
Push (APNs — agent)KNOCK_APNS_CHANNEL_IDregisterDevice with app: 'agent' (agent apps)
Push (APNs — customer)KNOCK_APNS_USER_CHANNEL_IDregisterDevice 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 + 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:

Terminal window
bunx knock whoami --service-token "$KNOCK_SERVICE_TOKEN" # verify auth
bunx knock pull --service-token "$KNOCK_SERVICE_TOKEN" # dashboard → knock/
bunx knock push --commit -m "msg" --service-token "$KNOCK_SERVICE_TOKEN" # knock/ → Development + commit
bunx knock commit promote --only=<commit-id> --force --service-token "$KNOCK_SERVICE_TOKEN" # one commit → next environment

push 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.

┌──────────────────────────────────────────────────────────────────┐
│ 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.

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).

For full Knock testing in local dev:

  1. Use a Knock dev environment (create one in the Knock dashboard separate from staging / prod).
  2. Set all the env vars above in apps/svelte-web/.env.local, apps/api/.env.local, and apps/worker/.env.local.
  3. For Knock webhook testing (Workstream B), expose your local web server via ngrok and point the dashboard webhook at the public URL.

Cookies + signed tokens work identically in dev — no special bypass.