Four rules for notification preferences
The type-by-channel grid is the easy part, and most teams get there on their own. These are the four things that break once you have it.
That preferences are a grid of notification type by channel is not the interesting part. Most teams get there on their own, usually right after someone turns off all email and then misses a shipping update.
The interesting part is what breaks once you have the grid. Four rules, each learned the slow way.
Store overrides, not the grid
If you copy a type's defaults into every contact at signup, you have frozen them. Change a default later and existing contacts keep the old one forever, and you have no way to tell a deliberate choice from a stale copy.
Store only what someone actually changed. Resolve at send time: start from the type's defaults, apply that contact's overrides, deliver to whatever survives.
"Cannot opt out" does not mean "always sent"
Password resets and security alerts should not be optional, so mark those types as not allowing opt out and ignore the recipient's preference for them.
Then keep the two settings apart. The type's channel defaults still decide which channels fire. All that changes is that the recipient loses their veto, so a non-opt-out type with email off in its defaults sends no email. Collapse those into one flag and you have built a class of message that ignores your own configuration.
We got this wrong the first time.
One consequence: leave those types off the preferences screen entirely. A switch that silently does nothing is worse than no switch, because the person flips it, believes they have opted out, receives the message anyway, and now distrusts every other control on the page.
Only offer a channel you can actually deliver on
If someone enables push for a type that has no push template, the preference saves, they believe push is on, and every send fails quietly with nothing to render.
Treat a channel as available only when a template exists for that type and channel. No template, no cell.
The appeal is that it maintains itself. Write a push template and push appears. Delete it and it disappears. The alternative is a separate list of supported channels per type, which is one more thing to keep in sync and which will be wrong within a month.
A config change must not delete a choice
When an operator marks a type as non-opt-out, the tempting cleanup is deleting the contact preferences that no longer apply.
Leave them. They go dormant, not away. Policies get flipped by mistake and flipped back, and if you deleted the data then every recipient silently reverts to defaults with no way to recover what they had asked for.
The general form: an operator's configuration change should never destroy an end user's choice. Ignore it while it does not apply, and honour it again when it does.
One API for in-app, email and push
Per-tenant branding, per-contact preferences, and a real-time inbox with no polling, plus a self-host option so the exit stays open. Free tier, no card.