An invited member who cannot see the workspace
The invite is parked in one of the states it passes through, and the admin console renders most of them identically. Walk the states, find yours, stop reading.
In short
An invited user who cannot access a workspace is parked in one state of the invite: never sent, sent but silently skipped, accepted under a different address, or a member row written to one of the two places membership lives. Address mismatch is the most common. Org invitations expire after 14 days.
Three strings bring people to this page. User with email [email protected] not found, ask them to sign up first. You are not allowed to accept this invitation. And the worst one, a bare 401 with no body at all, returned by
every route under a workspace the person was definitely added to.
They are three different states of the same object. An invite moves through a sequence, and a member who cannot see the workspace is parked at one of the steps, not suffering from one generic problem with one generic fix. So walk the sequence. At each step, what the admin sees and what the invited person sees diverge in a specific way, and the first place those two accounts disagree is your answer.
First, which invite is this
There are two, they share a word, and they are not the same machine.
An organization invitation is a control-plane object. It lives on central,
at /api/organizations/users/invitations, it has a real status field
(INVITED, ACCEPTED, REJECTED, EXPIRED), and it is how you get a
colleague into the BuildBase console.
A workspace member is a row in your own tenant database, created by
POST /api/v1/public/workspaces/:workspaceId/users/add. This is the one your
app's end users hit when they add a teammate to "Demo workspace".
Key takeaway
Workspace membership has no pending state. The add is synchronous: either the person is a member when the request returns, or the request failed. Nothing is waiting to be accepted, so "they haven't accepted yet" is never the answer on that path.
That single difference kills most of the states below for the workspace path. I will walk both and say which applies.
State: sent
Org invitation. The row is created with status: INVITED and validTill
defaulted to 14 days out. That number is the model default and the
INVITE_VALIDITY_DAYS constant in the invitations route, and the two agree on
purpose. The invitations screen shows the row straight away. The invited person
sees nothing yet.
Workspace add. There is no sent. Before anything is written, the route looks the address up:
const newUser = await UsersController.getInstance().findByEmail(
orgId,
req.body.email
);
if (!newUser) {
return res.notFound(
`User with email ${req.body.email} not found, ask them to sign up first`
);
}If no account holds that address, the caller gets a 404 and nobody is contacted.
Worth knowing what the wire message actually looks like: res.notFound(entity)
renders not found ${entity}, so the body reads not found User with email [email protected] not found, ask them to sign up first. The doubled phrase is
the helper, not a bug in your parsing.
Admin sees: a 404, immediately. Invited person sees: nothing, and never will. This state is loud, which is why it is rarely the one people are stuck in.
State: delivered
This is the first quiet one, and it is where a lot of "I invited them and nothing happened" actually lives.
On central the invitation is created first and the email is fired afterwards,
unawaited, with a rejection handler that returns undefined. The comment in the
route is explicit about why: a relay that is down must degrade the notification
rather than fail the request that made it. Sound call. The cost is that five
separate conditions each return false and write nothing the admin can see:
- BuildBase is not configured on that deployment
- the organization has no workspace yet, which is normal until the owner's first switch, and is logged at info level
- the workspace or the audience record cannot be resolved
- the
org-invite-sentnotification event has not been created - the trigger call comes back non-2xx
On the tenant side the workspace-added email runs a four-layer gate before it
reaches the queue: the org-wide email switch, the event's own kill switch, the
per-event channel default, and a workspace-level override when the event is user
managed. Each returns false on its own. Then the mail worker has its own set of
skips, and these ones at least leave a trace: Audience member has unsubscribed,
Audience member has invalid email, Audience member is blocked,
User is blocked, or Unsubscribed from group <id>. Each writes a SKIPPED
row into email history with the reason on it.
Admin sees: a pending invitation, or a member who is already in the list. Nothing wrong anywhere. Invited person sees: an empty inbox.
So, does a failed send leave the invite in a state the admin cannot see? Not
quite, and the distinction matters. The invitation row is perfectly visible. It
is the failure that is invisible, because it happened in a promise nobody is
holding. Your two places to look are the email history screen, filtered to
SKIPPED, and the central log for a line beginning [BuildBaseNotify].
State: opened
There is no opened state. Nothing records whether the message was read, for either path, and you should not go looking for a tracking field that does not exist.
One thing worth checking before you blame the recipient, though. The
workspace-added email has no accept link in it, because there is nothing to
accept. The default template's subject is You have been added to {{workspaceName}} and the body says who added them and at what role. If your
support reply is "ask them to click the link in the email," you are sending
them to look for something that was never there.
State: accepted
Org invitations only. PATCH /api/organizations/users/invitations/:id/accept
runs five checks in a fixed order, and the order is the diagnosis:
- The invitation exists.
- Its status is still
INVITED. Anything else answersInvitation already accepted/rejected/expired. validTillis in the future. If it is not, this call is what flips the row toEXPIREDand answersInvitation expired.- The organization and the user both resolve.
sameEmail(invitation.email, user.email). Failure here isYou are not allowed to accept this invitation.
Step three is the one that surprises people. Nothing sweeps expired invitations,
so a row that timed out eight days ago still reads INVITED in the database and
on the admin's screen. It expires at the moment somebody tries to use it. The
invitee's own screen filters expired rows out, which is the right call for a list
of things you can act on, and it is also why the admin sees a pending invitation
that the invitee cannot find anywhere.
The fix is resend rather than revoke-and-reinvite. Resending keeps the same row,
the same inviter and the same role, and winds validTill forward 14 days from
now rather than adding to a date already in the past. There is a five minute
cooldown per invitation and a ceiling of sixty invitation emails per organization
per hour, both of which answer with a 429 that tells you how long to wait.
State: member created
Now the workspace path gets interesting, because membership is stored in two places and they are written by two separate statements.
addUserToWorkspace does step one, $addToSet the user id onto the workspace
document's users array. Then step two, create the row in the
workspaces_users junction collection. If step two throws, step one is rolled
back with a compensating $pull, which is a sensible thing to do and is also
itself a write against the same database that just refused one. It can fail too.
Why does any of this matter to a person staring at an empty workspace switcher? Because the two records answer different questions:
| Question | Read from |
|---|---|
| Which workspaces do I see? | the users array |
| May I do anything inside this one? | the workspaces_users row |
getMyWorkspaces filters on users: { $in: [userId] }. isWorkspaceMember,
which every route under /workspaces/:workspaceId/users runs first, reads the
junction. So the two possible splits produce two completely different symptoms:
- Junction row, no array entry. Every API call inside the workspace works. The workspace never appears in their list. They can be handed a direct link and it functions, which makes this one look like a frontend bug for about a day.
- Array entry, no junction row. The workspace appears in their list, and
every request inside it returns a bare 401. Not a 403, not a message.
res.unAuthorized()issendStatus(401), so there is no body to read and nothing in the response that tells you membership was the problem.
Query both. If they disagree, that is the whole answer and no amount of role-checking will help.
The org path has its own version of this. Accepting creates the membership row on central, and that is all it creates. The tenant user row does not exist until the person's first org-switch, which can be days later. Central is authentication and membership; the tenant database is authorization. Until they switch in at least once, they are a member of an organization with no presence in the database that would let them do anything.
State: role assigned
The inviter picks a role. Whether that role survives the trip depends on which path you are on.
Workspace. The role has to be one the workspace defines, or the add is
rejected before it writes: Role editor is not valid, valid roles are admin, viewer. Loud failure, easy to spot.
Org invitation. Central stores a tenantRole string and forwards it
verbatim, because it cannot read your tenant's roles and has no way to check it.
The tenant decides at org-switch, and it applies the hint under two conditions:
the role has to exist in that org's access controls, and only a brand-new local
user can be seeded with it. An unknown name falls back to the membership tier.
An API-kind role is treated exactly like an unknown one.
So the invitation says editor, the console member list shows editor, and the
person is signed in as a plain member because no role by that name exists in the
tenant. Nothing errored. Nobody was told.
State: permissions live
Workspace permissions resolve in three steps, first match wins:
workspace.permissions[role], then the org-level template in
orgSettings.permissions[role], then the built-in
DEFAULT_WORKSPACE_PERMISSIONS[role], falling back to the editor list when the
role has no entry of its own.
That fallback is worth a second look if you have defined custom workspace roles.
A role nobody wrote a permission list for does not end up with nothing; it ends
up with whatever editor has, which may be considerably more than you intended.
Meanwhile viewer holds exactly two permissions by default,
workspace:settings:view and workspace:members:view, so a viewer who "cannot
see billing or usage" is working correctly and needs a different role rather
than a bug report.
And the boring case, which is also a real one: everything above is correct and
the change simply has not landed yet. SESSION_DEFAULTS.permissionRefresh is
hourly. If you changed a role two minutes ago and the member's console has not
noticed, wait, or have them sign out and back in, which re-reads the role on the
way through.
The two states that look identical from the admin side
Here is the payoff. Both of these render as a healthy-looking member row with the right role next to it, and they need opposite fixes.
Accepted, but the role never arrived
Described above: the invitation carried a tenantRole the tenant does not have,
or the person already had a local user row so the hint was ignored on purpose.
Central's member list is showing you central's record of what was invited. The
tenant's users.role is what the session actually carries.
Tell them apart by asking the tenant rather than the console. The role in the
member list is the intent; the role on the local user row is the outcome. When
they differ, use change-role on the tenant side. Re-inviting will not fix it,
and inviting somebody who is already a member is refused outright with
That person is already a member of this organization. Inviting them again does not change what they can do.
Accepted under a different address
This is the one. In my experience it beats every other cause on this page combined, and it is invisible from the admin console by construction.
Everything here keys on the email address. The address the account was created
with is not always the address you invited, and nothing reconciles them. Central
folds invitation addresses to lowercase on write and matches case-insensitively
on read, and accounts are stored lowercased too, so capitalisation is genuinely
handled. The
comment in central-server/src/utils/email.ts records exactly what happened
before that was true: an invitation to [email protected] and the account
[email protected] were different strings to every query, the invitation did not
appear on the invitee's screen, and the product behaved as though it had never
been sent.
Case is fixed. These are not:
- A plus tag.
[email protected]reaches the same mailbox and is a different address everywhere in this system. The Mongo filter escapes the+rather than stripping it, deliberately, because an unescaped one would match addresses it has nothing to do with. - A different provider account. Console sign-in is OAuth, and the account is keyed on whatever address the provider returns, lowercased. Invite the work address, sign in with the personal account, and you have two accounts.
- An alias that forwards. The mail arrives. The account is under the address it was actually created with, not the one that forwarded.
Now the asymmetry, which is the reason this is so hard to see. The invitee's own screen lists invitations matching their account's address. No match means an empty screen: not an error, not "this invitation is for someone else", just nothing to accept. The admin's screen lists invitations by organization, so it shows a perfectly healthy pending invitation to the address they typed. Both screens are correct. They are describing different rows.
Pushing through by opening the accept endpoint directly gets you
You are not allowed to accept this invitation, which is the only place in the
whole flow that says the addresses do not match, and almost nobody reaches it.
On the workspace path the same mismatch shows up earlier and, honestly, better:
you get the 404 from findByEmail up front. The genuinely dangerous version is
when the address does resolve, to an account that is not the person you meant.
Nothing asks you to confirm. Somebody is now a member of Demo workspace and it
is not who you think.
Warning
Ask the person to read you the address on their account profile rather than the one they think they signed up with. Comparing it to the invitation is a ten-second check that settles this, and it is the check people skip because they are certain they know the answer.
A quick way to see which side is lying
The two ends disagree, so read both ends. From the invited person's session, what comes back is the shortest signal available:
import { useSaaSWorkspaces } from '@buildbase/sdk/react';
export function MembershipDebug() {
const { workspaces } = useSaaSWorkspaces();
// Empty, or missing the one they were added to, means the `users` array
// on the workspace document does not contain their id. A workspace that
// is listed but 401s on every call means the junction row is missing.
return (
<pre>
{JSON.stringify(
workspaces.map((w) => w._id),
null,
2
)}
</pre>
);
}I have not run that against a live server while writing this; it is the
useSaaSWorkspaces shape from the canonical SDK snippet with a render swapped
in, so treat it as a sketch rather than tested code.
The other end is the event stream. organization.member_invited and
organization.member_accepted are both webhook events and both Slack events.
Subscribe to the pair and the invite stops being a black box: an invited with no
accepted is parked before acceptance, and an accepted with a member who still
cannot see anything moves the whole investigation to the tenant side. For the app
path the equivalent is workspace.member_added, which fires from the add route
itself, so its absence means the add never completed.
What actually tends to be wrong
Ordered by how often I have watched it happen, not by how interesting it is:
- The address on the account is not the address on the invitation.
- The email was skipped silently and the invitation is sitting there unseen.
- The invitation expired at 14 days and still reads
INVITEDto the admin. - The two membership records disagree.
- It has been ten minutes and permissions refresh hourly.
Four of those five are invisible from the admin console. That is the real shape of this problem, and it is why "I invited them, it says pending, I don't know what else to tell you" is such a common place for this to stall.
If you are building the workspace and membership model rather than debugging one, multi-tenant workspaces in an afternoon covers the model itself, and charging per seat covers the other reason an add gets refused: the seat cap, which returns a 402 with the current count and the limit on it rather than failing silently.
Install
npm i @buildbase/sdk