A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.
A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.

Invitations needed a notifications layer

Jesse James Richard
|
Feb 26, 2026
|
8 min read
#Access Control
#Architecture

The smallest feature I built this winter quietly forced the largest one. An owner invites a teammate to their organization. Three sentences of product. But a teammate who is not a user yet cannot be reached inside the app, because they are not inside the app, and reaching a stranger by email turns out to require a whole system I did not have. One invitation runs from the click that sends it to the teammate sitting inside the org with the right access, and what that click required underneath it was the larger build.

Why invitations exist at all

Giant Context is multitenant. A customer is an organization, and organizations are sealed off from each other completely. But the people are not sealed off, because real work does not respect those boundaries. A freelance designer builds sites for six different companies. An agency runs twenty client accounts. A consultant drops into a project for a month and leaves. If a person had to make a new account for every organization they touched, with a new password and a new identity, the product would be describing a world that does not exist.

So the model is one person, many memberships. You are one identity on the platform, and you hold a seat in every organization that has invited you, with a possibly different role in each. An owner in your own company, an editor in a client's, a viewer in a third. The invitation is the mechanism that adds a seat without creating a person. That is why it exists, and why it cannot just be a row that says this email may enter. It has to work for someone who has no account yet, and it has to work for someone who already has one and is simply joining another org.

The click

An owner opens their members list, enters a teammate's email, picks the role that teammate will hold, and sends. On my server, that creates an invitation record with a securely generated random token and an expiry twenty-four hours out, and the token is the whole security model. It is a secret long enough that nobody guesses it, carried in a link, and it is the only key that opens this particular invitation.

A dark-themed user interface modal titled 'Invite Member' with fields for email address, organization role, and project memberships.
The invite dialog. An email, a role, and a send.

Now the invitation has to reach a human who, by definition, is not in my app. And that is where the problem gets bigger, because sending email is its own discipline. It sucked thirty years ago and it still sucks today.

The notifications layer underneath

I cannot send email. Not really. A platform that sends mail from its own servers has that mail filed under spam within a week, because inbox providers trust established senders and distrust new ones, and earning that trust is a specialty business with a full-time job attached. So the outbound side of any production platform runs through a third-party email provider that has already earned deliverability, and my code hands the message to them. To my surprise, GCP does not have this surface. Part of my ideology is to keep everything under one roof, and here that was impossible.

Invitations are one of a dozen things Giant Context needs to say to people. Password resets, billing warnings, notifications, the lot. If every one of those wired itself directly to a mail vendor, then swapping vendors, or adding a second channel, means editing a dozen features. So the invitation does not send email directly, because an email notification is only one channel. A notification can reach an inbox, a device, and a badge inside the app itself. Behind the email I needed the system that decides which of those to use.

At its center is one small contract, and everything else plugs into it:

The provider interface

export interface EmailProvider {  send(options: EmailOptions): Promise<EmailResult>;}

Behind that interface sits whichever provider the environment chooses. In production, the third-party deliverability service. Locally, MailHog, a fake mail server that catches everything the app sends and shows it in a web inbox. That last one is how I proved the flow worked. I clicked invite, opened MailHog, and there was the real email with the real link, which I clicked and accepted, and not one message ever left my machine. It is a permanent fixture of the dev environment now, and half of building email is being able to see the email without spamming yourself.

The invitation feature knows none of these names. It hands a message to the notifications system and that system decides how it goes out. That system is a story of its own.

The link, and who may use it

The teammate gets an email with a link that carries the token. They click it. Two cases from here, and a product has to handle both.

If they have no account, they make one, and only then does the token get spent. The identity comes first, the seat second.

If they already have an account, from another organization they belong to, they are simply added to this one. No second identity, no second password. The same person, now holding one more membership.

Either case ends at the same gate, and the gate checks one thing that matters. The email on the accepting account must match the email the invitation was sent to. An invitation to maria@agency.com can only be accepted by the account that owns maria@agency.com. This closes the obvious hole of a forwarded link or a token pasted into the wrong hands. An invite gets one person into one org, and only the person it names.

A screenshot of a dark-themed web application interface showing an Invitations tab within a settings menu.
A pending invitation in the members list, waiting on the person it names.

Seated

The token is spent, the membership row is written, and the new teammate lands in the organization holding exactly the role the owner picked. Their next request through the platform crosses the same permission gates as everyone else, and now those gates know them.

The pending invitation flips to a filled seat. The owner sees a teammate instead of a waiting email.

Wiring this up yourself

If you are building multitenant software, the invitation is not a feature you bolt on late. It is the foundation of your entire membership model, so build it early and build it as a token flow. Which means you need a notification system under it. Eventually you may want SMS or push as well.

Make the token the security model. A long random secret in a link, a short expiry, single use. Do not verify by anything the sender controls, verify by the email the accepter proves they own.

Design for both accepters from the first line of code. The new user who has to sign up, and the existing user joining another org. If you only handle the first, every agency and freelancer using your product hits a wall.

And when you find that your invitation needs to email someone, stop, and do not wire it to a vendor. Build the notifications layer first, provider behind an interface, and route the invite through it. You are going to need to send people mail for a hundred reasons. The invite is just the first one that admits it out loud. I built the notifications layer because one small feature needed it, and everything since has run through it.

One domain, connected

#Architecture

For years I used Firebase's custom-domain feature and never knew how it worked. Last week I had to build that green check. Connecting a domain runs th...

Jesse James Richard

|

Feb 23, 2026
Read previous

Building something like this

I'm Jesse. I build platforms end to end, and I'm open to work. If this is the kind of engineering you need, get in touch.

Contact Jesse
Home
About
Contact
Sitemap
Privacy Policy
Terms of Service
Cookie Policy

Some posts on this site may be updated on occasion.