type: decision
status: active
timestamp: 2026-06-20
tags: [decisions, architecture, notifications, push, knock, fcm]
status: active
timestamp: 2026-06-20
tags: [decisions, architecture, notifications, push, knock, fcm]
Notifications — FCM (transport) + Knock (orchestration)
Two-layer notifications: Knock + FCM' (in-app + email + SMS + web push); FCM stays as the web-push transport. Free 10K notifs/mo on Knock, free unlimited on FCM.
Notifications — FCM (transport) + Knock (orchestration)
Decision
Notifications are split across two layers:
- Orchestration — Knock. One workflow per event type (billing receipt, password change, comment reply, feature rollout). Knock owns channel selection, delivery order, dedupe windows, digest, per-user preferences, and the in-app feed. Free tier: 10,000 notifications/month.
- Transport — FCM for web push; Resend for email; Knock-bundled Twilio/MessageBird for SMS.
Knock’s web-push channel dispatches to FCM. Knock’s email channel dispatches to Resend. SMS is pay-per-message via Knock’s bundled providers (no monthly fee).
Why
- Building cross-channel orchestration on Workers is months of work. Knock gives us preference centers, digest windows, dedupe, branch-based environments (dev / staging / prod), and an in-app feed React component for free.
- FCM stays as transport because it’s free unlimited on Firebase Spark (locked forever by firebase-spark-forever), has first-class iOS PWA support, and tokens already live on Firebase Auth user records — letting Knock send web push directly would duplicate that storage and cost extra Knock notifications against the 10K cap.
- Resend stays as email transport for the same reason — it’s already wired for transactional email, free 3K/day, and Knock can point its email channel at Resend’s SMTP credentials.
- No card required at any layer. Spark + Resend + Knock all free-tier; per-site env-var toggle (same pattern as Sentry) keeps low-traffic sites from burning the 10K Knock cap.
Implications
Architecture
- One workflow per event type, defined in Knock’s dashboard.
Workflows are exported as JSON and committed under
packages/oriz-kit/notifications/workflows/for replayable setup. @chirag127/oriz-kitships a<KnockFeed />component wrapping Knock’s React feed. Every site mounts it in the<AccountPanel>notifications drawer.- Firebase Auth stores the per-user Knock recipient ID + FCM token on the user profile. The Hono Worker syncs Auth user changes to Knock recipients via webhook.
- Razorpay / Lemon Squeezy webhook ? Hookdeck ? Hono Worker ? Knock workflow trigger. Hookdeck stays in front for retry / replay.
Channel responsibility
| Channel | Owner | Why this split |
|---|---|---|
| In-app feed | Knock-hosted | Drop-in component; no DB schema needed |
| Web push | FCM (via Knock) | Free unlimited Spark + iOS PWA |
| Resend (via Knock) | Already wired transactional | |
| SMS | Twilio / MessageBird (via Knock) | Pay-per-SMS only, no monthly fee |
| Slack | Knock direct | Ops alerts only; no per-user use |
Volume budget
- 10K notifications/month on Knock = ~330/day across the family.
- Per-site
ENABLE_KNOCK=true|falseenv var keeps low-traffic sites silent until they need notifications. - If we hit the cap: digest workflows (Knock supports daily / weekly digests natively) collapse multiple events into one notification, dropping volume by 5–10×.
What we don’t do
- No direct Knock web-push — every web-push goes through FCM.
- No self-hosted Novu — per .
- No Firebase Cloud Functions for notification fan-out — that would force Blaze (paid) to call third parties; Hono Worker on Cloudflare handles it free.
- No card-on-file at Knock or Twilio. SMS budget caps configured on the Twilio side via prepaid balance only.
Cross-refs
- Knock service entry
- FCM service entry
- push services index
- Resend — email transport behind Knock
- Firebase Spark forever
- Hookdeck — webhook reliability
- Never hit quotas rule
- No card-on-file rule