Guides
Build guide

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.

Dharmendra Jagodana10 min read

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 transactional

The 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
notifications
email
workspaces

Frequently Asked Questions

Should transactional emails include an unsubscribe link?

Usually not required, and the exception is the one that catches people out: the moment a receipt or a service notice carries promotional content, it reads as commercial mail and the rules that apply to commercial mail apply to it. Plenty of senders add a link to everything anyway, because mailbox providers treat a visible opt-out as a better signal than a complaint. Check the rules for the jurisdictions you send into rather than copying another product.

What wins, the workspace default or a member's own setting?

The member, unless the workspace marked that event required. One function resolves it for both the settings screen and the send path, so the two cannot disagree: a member choice is used if there is one, otherwise the workspace default, otherwise on. A required event refuses the member write outright with a 409 naming the locked events, rather than storing a preference the gate then ignores.

Can users opt out of specific notification emails?

Only for events you marked user-managed. Everything else sends regardless of what any preference row says, because the gate does not read preferences for an event that is not user-managed. That is why the classification is a real decision and not a settings screen: the screen can only ever show what you opted in.

Does unsubscribing from everything stop every email?

No, and this is the part that surprises people. The check returns early when a template has no unsubscribe group attached, before it ever looks at the all-off flag. So "unsubscribe from all" means all groupable mail. A template you never assigned to a group keeps sending, which is correct for a receipt and wrong for the digest you forgot to categorise.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime