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
The obvious implementation writes the whole grid per contact at signup. Six notification types across three channels is eighteen rows for someone who has never expressed a preference about anything:
The grid, copied per contactcontact type channel enabled
c_8213 appointment_booked email true
c_8213 appointment_booked inbox true
c_8213 appointment_booked push true
c_8213 appointment_reminder email true
... 14 more rows, every one a copy of a default
Those copies are frozen the moment they are written. Change the default for
appointment_reminder next quarter and every existing contact keeps
the old one, because a copy of a default is indistinguishable from a deliberate choice. You
cannot migrate your way out of it either. There is no query that tells you which of those rows
somebody meant.
Store only what changed.
The same contact, after they turn off one thingcontact type channel enabled c_8213 appointment_reminder push false
One row, and it means something. Resolve at send time: start from the type's defaults, apply this contact's overrides, deliver to whatever survives. A new default then reaches everyone who never had an opinion, which is almost everyone.
"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 that flag away from the separate question of which channels fire. We collapsed the two the first time, and what that produced was a class of message immune to our own configuration: turn a channel off in the type's defaults and a non-opt-out type sent on it anyway. The only way to stop such a message was to delete its template.
The type's channel defaults still decide. All the flag removes is the recipient's veto, so a non-opt-out type with email off in its defaults sends no email.
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
Enable push for a type that has no push template and the preference saves cleanly. The contact believes push is on. Every send after that fails with nothing to render, and it fails quietly, because nobody files a support ticket about a notification they never knew was coming.
Treat a channel as available only when a template exists for that type and channel. No template, no cell in the grid.
The appeal is that it maintains itself. Write a push template and push appears. Delete it and
it disappears. It also costs one query, whatever the channel count: a distinct
(channel, type) projection over the template table, folded into a
map of type to available channels.
The alternative is a "supported channels" list per type, which is a second source of truth somebody has to remember to update, 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. They are dead rows, the argument goes.
Leave them. They go dormant, not away. Policies get flipped by mistake and flipped back on Monday, and if you deleted the rows then everybody silently reverts to defaults with nothing to restore, because what they asked for is gone.
The principle underneath, for the cases the first three rules do not cover: your configuration change may override someone's choice. It may not destroy it. Ignore the choice 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.