Which notifications users get to turn off, and which stay on
Which notifications should be mandatory, which users may switch off, and how the opt-out is actually enforced at send time.
In short
Make security, billing and account-lifecycle mail mandatory, and let users turn off anything whose purpose is engagement. The split is decided per event, by a flag on the event and by whether the template carries an unsubscribe group. Neither default is the one most teams assume.
Nobody decides which notifications users can turn off. They inherit it, then find out what they inherited when a customer forwards a 6am digest and asks why the off switch did nothing.
The list is not a setting. It is the residue of two defaults that point in opposite directions, and neither one is the default most teams assume they have.
Key takeaway
Make mail the account depends on mandatory: security, payment failures, plan state, invitations. Let users turn off anything whose job is engagement. Then check both halves actually hold, because the preference flag and the unsubscribe group are separate mechanisms and a notification can escape through either one.
What you are building
A preferences screen that lists the right events, backed by a gate that honours it. When it works, a member opens workspace settings, sees the notifications you decided they may control, switches one off, and stops getting it - while the payment-failed email still arrives, because you never offered that one as a choice.
What you are not building is a screen that lists everything you send. That screen is a liability. Every row on it is a promise that the switch does something, and the gate only reads preferences for events you explicitly opened up.
Before you start
A workspace with at least two members, so you can tell a workspace default apart from one member's own choice. You also need the email module configured far enough to actually send, otherwise every test looks like a pass.
One trap before the code. These methods live on the low-level WorkspaceApi,
which you reach through client.forSession(id). That is not the
withSession(id) helper that
the Express metering guide happens
to name forSession: same word, different object, and the scoped one's
.workspace has none of these methods while its .notification only sends.
The snippets below are written against the SDK's typed surface rather than
copied from a run, so check them against your own client before pasting.
Step one: sort your events by who is hurt if it does not arrive
Before any code, split what you send into two piles. The test is not whether the email is useful to you. It is whether its absence hurts the recipient.
Mandatory, in practice, is narrower than most teams fear and wider than the word "transactional" suggests:
- Security and account state. A password change, a sign-in from a new device.
- Money. A failed payment, a card that needs action, a suspension.
- Plan state the user cannot discover in time any other way. A trial about to end.
- Being added to or removed from somewhere. An invitation, a role change.
Optional is everything whose purpose is engagement: digests, activity summaries, product updates, usage nudges, the weekly roundup nobody asked for. If the honest reason the email exists is that you want them back in the product, it belongs in the pile the user controls.
The grey zone is real and worth settling out loud. Is "invitation accepted" mandatory? The invitee needs the invitation. The person who sent it does not need to know it was accepted - that is a courtesy, and courtesy mail is exactly the kind that accumulates into noise. Put the invite in the mandatory pile and the acceptance receipt in the optional one, and write the reasoning down before someone argues about it in a support thread.
One category sits outside both piles. The mail that carries the mechanism itself - a sign-in link, a password reset, an address verification - is not a notification at all. It is the thing the user just asked for. It never reaches the gate, and there is no row for it on any screen, because an opt-out on it would be an opt-out of signing in.
Step two: find out what your defaults already are
The seeded system events and the events your own code registers get opposite treatment. That is where the inheritance bites.
The 20 events seeded into a new organization - six workspace, four billing, two trial, four payment, four account - are all created the same way:
{
system: true,
enabled: true,
userManaged: false,
channels: { email: true, push: true },
}Read that twice. userManaged: false on all 20. Email and push both on. So a
preferences screen built straight out of the box lists nothing at all, while
every one of those 20 sends on both channels to whoever the notifier names -
the workspace owner for most of the billing and trial mail, the user for the
account mail. The off switch is not broken. There is nothing for it to show.
Now the other direction. When the SDK sends a notification for an event that does not exist yet, the event registers itself on that first send:
{
system: false,
enabled: true,
userManaged: true,
channels: { email: false, push: true },
}Inverted. Custom events are user-managed by default, and their email channel starts off. So the digest you built yourself is the one users can mute, and it is not emailing anybody until you turn that on, while the 20 you never looked at are the ones they cannot escape.
The asymmetry is defensible in the abstract - a system event is usually account state, a custom event is usually engagement - but it is a guess made on your behalf, and step three is where you find out it is not negotiable. It guesses wrong for any product whose seeded mail skews chatty, and wrong again the moment you register a custom event for something genuinely important.
Step three: open up what you can, and find out what you cannot
Flip userManaged on the events from your optional pile - and here the
asymmetry from step two stops being a default and becomes a wall. The update
route branches on whether the event is seeded:
// System events: only allow toggling channels (enabled is derived from channels)
if (event.system) {
// ...
// System events cannot change: slug, name, description, category,
// userManaged, emailTemplate
}So userManaged is a lever on your own custom events only. Send it for one of
the 20 seeded events and the field is dropped - not a 400, not a 409, a 200
with the change quietly discarded. Worth knowing before you spend an afternoon
wondering why the screen stayed empty.
Which means the seeded half is not a default you override. It is one you are stuck with. The only control a seeded event offers is its channels, and that is org-wide and all-or-nothing: you can stop sending the sign-in alert by email to everybody, or keep sending it to everybody. There is no per-user middle. If a seeded event genuinely belongs in your optional pile, that gap is the thing to plan around, not a setting to hunt for.
With the custom events opened up, the listing endpoint has something to return:
import { BuildBase } from '@buildbase/sdk';
const buildbase = BuildBase(config); // your existing setup-guide client
// The low-level WorkspaceApi, bound to one signed-in session. Not the same
// object as buildbase.withSession(sessionId).
const ws = buildbase.client.forSession(sessionId).workspace;
// Only events that are userManaged AND enabled come back.
const events = await ws.getNotificationEvents(workspaceId);What you should see: the custom events you just opened, each with its slug, name, description, category and channel defaults - and nothing else. An empty array on an untouched setup is the expected result rather than a bug, because the only events in a fresh organization are the 20 that cannot be opened.
Two levels write to it. The workspace default is what every member starts from and needs the settings-edit permission. A member's own choice needs only membership:
// The workspace default, for every member who has not chosen.
// Needs the settings-edit permission. Merged per event: send only what changes.
await ws.updateNotificationPreferences(workspaceId, {
weekly_digest: { email: false },
});
// One member's own choice, over that default. Any member may call it.
await ws.updateMyNotificationPreferences(workspaceId, {
weekly_digest: { email: true },
});Resolution is one function, deliberately, so the screen and the send path
cannot disagree: a member's own choice wins, unless the workspace marked that
event required, in which case the default is what they get and their choice
is not read. No choice anywhere means on.
Send null for a channel rather than false to clear a member's choice and
drop them back to the workspace default. It is the difference between "I want
this off" and "I have no opinion", and only one of them should survive an
admin changing the default later.
Tip
A required event refuses a member's write rather than accepting it and
ignoring it. You get a 409 naming the locked events, not a silent success
followed by mail the user believes they switched off.
Step four: the unsubscribe group is a second, separate gate
The preference flag governs notification events. Bulk email runs through an entirely different check, and the two do not know about each other.
That check begins:
if (!unsubscribeGroupId) return false; // no group means transactionalThe all-off flag is read after that line. Which means a user who unsubscribed from everything still receives any template you never assigned to a group. For a receipt that is exactly right. For the product-update blast you shipped in a hurry, it is a complaint, and the user is correct: they did turn it off, and you sent it anyway.
So the audit is two columns, not one. For every message you send: is its event user-managed, and does its template carry an unsubscribe group? Mandatory mail should have neither. Optional mail needs both. Anything with one and not the other is the bug you are looking for.
What breaks, and how you find out
The off switch that does nothing. A member switches something off and the
mail keeps coming. Almost always the event is not userManaged, so the gate
never reaches the preference layer. The row should not have been on the screen
at all. The listing endpoint filters to user-managed events precisely so this
cannot happen; a row that is there and does not work usually means a
hand-rolled screen that listed every event instead of calling it.
The typo that looks like it worked. Writing a preference for an unknown or non-user-managed slug is refused by name with a 400 listing the bad keys, rather than stored and quietly ignored. Good: a mistyped slug fails at the call instead of six weeks later. If you are wrapping the API, do not swallow that response.
Changes that take a moment to land. Event config is cached for 30 seconds and the org-level switch for a minute, both invalidated explicitly when you change them through the console. If you write directly to the database instead, expect up to a minute of stale behaviour and do not debug it as a gate bug.
Failures that send rather than drop. The gate fails open: an unexpected error, or no config row for the event at all, returns "allowed". The org-level switch is checked before that, so an organization with email switched off still blocks - but below it, an event you intended to be off is not safe merely because it has no configuration. Failing open is the right call for a payment-failure notice and the wrong one for a marketing blast. Off is a stored decision, not an absence.
The cost of a wide screen. The gate runs per recipient per channel, so an email-only digest going to 200 users is 200 gate checks, and one that also pushes is three times that, because the push side is evaluated twice per recipient. Member preferences are fetched in one query rather than per person, and the check itself touches nothing, so none of this is I/O. Not a scaling problem, then, but still a reason not to make every event user-managed on the theory that more choice is always better. Each row is a promise you have to keep.
Where to settle it
The screen is downstream of a classification, and the classification is the part nobody writes down. Write it down. Every message you send, a tick in each of the two columns, then a hard look at the rows with exactly one tick. That list is short, and it is where your opt-out is currently lying to somebody.
Related: team invitations covers the event most likely to sit in your mandatory pile, and transactional email landing in spam covers what happens when the mandatory half does not arrive at all.
npm i @buildbase/sdk