Guides
Build guide

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.

Dharmendra Jagodana13 min read

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-sent notification 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:

  1. The invitation exists.
  2. Its status is still INVITED. Anything else answers Invitation already accepted/rejected/expired.
  3. validTill is in the future. If it is not, this call is what flips the row to EXPIRED and answers Invitation expired.
  4. The organization and the user both resolve.
  5. sameEmail(invitation.email, user.email). Failure here is You 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:

QuestionRead 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() is sendStatus(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:

  1. The address on the account is not the address on the invitation.
  2. The email was skipped silently and the invitation is sitting there unseen.
  3. The invitation expired at 14 days and still reads INVITED to the admin.
  4. The two membership records disagree.
  5. 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
workspaces
invitations
rbac

Frequently Asked Questions

Do BuildBase invitations expire, and after how long?

Organization invitations expire 14 days after they are sent. That is the model default on validTill and the INVITE_VALIDITY_DAYS constant in the invitations route. Nothing sweeps expired rows, so status stays INVITED until somebody tries to accept or reject, and only then flips to EXPIRED. Resending winds the clock forward 14 days from now rather than extending the old date. Workspace membership inside your app is not an invitation and never expires.

Why does the console show the invitation as pending when the person never got an email?

Because the row and the email are two separate things and only the row can fail loudly. The notification call is fired after the invitation is created and its rejection handler swallows everything, so an unconfigured relay, an org with no workspace yet, or a missing notification event all return quietly. Look in email history for a SKIPPED row, or in the central log for a line starting [BuildBaseNotify].

Why do I get "ask them to sign up first" when I know that person has an account?

The lookup is an exact match on the folded address. Case and surrounding whitespace are handled; a plus tag is not, so [email protected] and [email protected] are two different accounts to this system. Signing up with a personal Google account rather than the invited work address produces the same 404.

How long after a role change before the member can see the workspace?

Permissions refresh hourly, so the slow path is up to an hour. Signing out and back in is the fast path because it re-reads the role on the way through. Membership itself is not cached that way: adding somebody to a workspace shows up on their next list request.

Ship it

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

7-day free trialNo credit card requiredCancel anytime