Skip to content

Organizations

Purpose: the building block for an application sold to companies rather than to individuals. An organization groups your end users, carries their membership tier, and gives you a boundary to scope authority to — so the same person can be an administrator in one customer's account and a read-only member in another's, with one login.

Organizations are optional. A tenant whose product has no notion of "a company" never has to create one; nothing else in the identity service depends on them.

The shape

An organization belongs to exactly one tenant. Membership is an edge from the organization to one of your users, never a copy of that user — so a person belongs to as many organizations as you invite them to, keeps one account, and carries a separate tier in each.

Each organization has:

FieldRules
nameWhat people see. 1–100 characters
slugA stable handle within the tenant: lowercase letters, digits and single hyphens, 1–100 chars
metadataA JSON bucket you own. A JSON object at the root, at most 16 KB serialized and 8 levels deep, and no NUL characters

Organizations are flat. There is no parent organization and no group inside one; sub-teams and organization hierarchies are deliberately not built, and neither is claiming an email domain to auto-join people.

Membership tiers

Every membership carries one of three built-in tiers. They are fixed — you cannot rename or remove them — and they inherit downward, so an owner satisfies a check for admin, which satisfies member:

TierWhat it means
ownerFull control of the organization, including its members, roles and settings
adminManages the organization's members and settings
memberBelongs to the organization without administering it

A tier is not a permission your application reads. It governs the organization's own management surfaces — who may invite, who may remove, who may end the organization. What a member may do inside your product is a role carrying permissions you registered; see permissions and roles.

A membership is addressed by the triple of organization, tier and person, and those three are set when the membership is created and never patched. Changing someone's tier means removing the one they hold and offering the new one.

Creating an organization

Your users create their own. On your tenant's own host, a signed-in user creates one at /organizations/new and becomes its accepted owner in the same write. This is the path a self-serve B2B product wants: the first person from a company signs up, creates their company, and invites their colleagues.

Your application creates one for its signed-in user. From inside your own product, POST https://{your-tenant-host}/api/organizations with that person's own access token makes the organization and their accepted owner membership in the same write the hosted page makes. It takes no identity:* scope and no dashboard credential — see organizations inside your own application below. The hosted page is unchanged and stays supported; this is a second way to reach the same write, not a replacement for it.

You create one for them. In your dashboard, DirectoryOrganizationsCreate organization, or through the management API:

MethodPathScope
POST/api/identity/{tenantId}/organizationsidentity:organizations:write
GET/api/identity/{tenantId}/organizationsidentity:organizations:read
PATCH/api/identity/{tenantId}/organizations/{organizationId}identity:organizations:write
DELETE/api/identity/{tenantId}/organizations/{organizationId}identity:organizations:write

Deleting an organization is a soft delete that frees its slug for reuse immediately — but only once the organization holds nothing. The delete is refused, 409, while the organization holds a live resource grant — a role you granted it on one of your resources. The refusal names those resources, up to three of them, so a DELETE that comes back 409 tells you which grants to deal with rather than leaving you to hunt for them.

A grant whose role definition you have since retired does not block the delete. It confers nothing while the role is retired and does not list on the resource either, so there would be nothing to revoke and no way out of the refusal.

The guard exists because a soft delete would otherwise take its members' access with it silently. Anything they reach through the organization — a resource your application granted the organization as a whole — resolves only through a live organization, so the moment the row is soft-deleted every member stops resolving on their very next request, and the grant row itself survives pointing at nothing. Refusing the delete puts that decision in front of the administrator instead of behind them.

So the order is yours to choose, deliberately:

  • Move the access. Grant the same role to another organization, or to the individual users who need it, and then delete.

  • Revoke it. Take the grants away with POST /api/identity/{tenantId}/resource-grants/revoke, then delete.

Members are still left alone by the delete itself: their memberships reference an organization that no longer resolves, which every read already filters on. And the delete stays soft, so the row survives for the audit trail — but no surface brings a deleted organization back today, so treat it as final.

Inviting members

Three surfaces make the offer, and all three write the same rows and the same audit events: an owner or admin on the organization's own page on your tenant's host, you in the dashboard under DirectoryOrganizations, and your application, acting as an owner or admin signed in to itPOST https://{your-tenant-host}/api/organizations/{organizationId}/invitations with that person's own access token, described in organizations inside your own application.

An offer names an email address and a tier or role:

JSON
{ "email": "[email protected]", "role": "admin" }

The address decides what happens next, and the caller does not choose:

  • The address already has an account in your tenant. The offer is written straight to a pending membership, waiting for that person to accept.

  • The address has no account yet. The offer is stored as an invitation against the address alone, and waits until someone signs up and verifies it.

The response says which happened, so your own UI can say the right thing. Offering the same address the same role twice is refused as a conflict rather than creating a second offer.

Only the second branch expires. An invitation written against an address with no account is acceptable for 7 days. A pending membership — the branch taken when the address already has an account — carries no expiry at all and waits indefinitely. Either way you withdraw one by deleting it, which frees the address to be offered that role again.

An id is not a capability on either branch, which is what makes an invitation link safe to hand out: one that reaches the wrong person buys them nothing. What each branch binds to differs, and it is worth knowing which you are handling:

  • An invitation converts only for a session whose user holds the invited address and has verified it, and only before it expires.

  • A pending membership converts only for the session of the person it was written for — it is claimed by user id, so the email is not re-checked at acceptance and an unverified address does not stop it.

Invitees accept on your tenant's own host at /organization-invite, where the offers waiting for them are listed.

We email the invitee on both branches. Creating an offer queues one message to the address it names, linking /organization-invite on your tenant's own host with that offer already selected. It goes out under your tenant's branding, through your tenant's own sender, and counts against the same email allowance as the rest of your mail. It carries no token — the id in the link is not a capability on either branch — so the message is safe to forward and worthless to intercept.

What it says splits the same way the offer does, because what the recipient has to do differs:

  • An address with no account is told to create one on that address, verify it, and then accept, and the message names the 7-day deadline.

  • An address that already has an account is told to sign in and accept. That message promises no deadline, because a pending membership has none.

Delivery does not gate the offer, and the response says nothing about it. The offer is written, returned to you, and listed to the invitee whether or not the message goes out — a send that fails costs a notification, not an invitation. If you need to be certain a person was told, send your own message too; what the offer wrote comes back to you in the response.

Three things suppress the mail while leaving the offer standing:

  • An address on your tenant's suppression list (a hard bounce or a spam complaint) is not mailed.

  • One inviter may cause at most 20 invitation emails an hour, across every address and every organization. This is a ceiling on volume, not on invitations: past it the memberships and invitations are still written and still listed, and only the mail is dropped. Invite a team larger than that in one sitting and some of them will not hear from us — tell those people yourself, or spread the invitations out.

  • One address is mailed at most 5 times an hour per organization. The budget is per organization on purpose: it bounds a repeated invite-and-withdraw loop against one inbox without letting one organization's sends silence another organization's invitation to the same person.

Invitations do not open a closed door. If your tenant's sign-up mode is closed or waitlisted, an invitee who has no account still cannot create one — an organization invitation is not a sign-up admission, and only a waitlist invitation is. Invite people who already have accounts, or admit them through the waitlist first.

Removing a member

Removing is per membership, not per person: a member holding two roles in one organization has two memberships, and removing one leaves the other. The last accepted owner cannot be removed, so an organization is never ownerless.

Bringing people back to your application

The three pages your users act on — create at /organizations/new, pick at /organizations, accept at /organization-invite — take a return_to, so somebody you send there lands back in your product instead of finishing on our page and having to find their own way home:

https://{your-tenant-host}/organizations/new?return_to=https%3A%2F%2Fapp.example.com%2Fteams

Where it may point is your own registered redirect URIs. An absolute URL is accepted only when its origin — scheme, host and port together — is the origin of a redirect URI you registered on one of your live applications. Any path on that origin is fine; it does not have to be the callback itself. The allowlist is deliberately that list and no other: an origin you registered already receives this tenant's authorization codes, so sending a person there grants nothing that registering it did not. A path on your tenant's own host (/organizations, say) is accepted too.

Four registrations do not qualify an origin, each for the reason sign-in already ignores it:

  • A wildcard registration (https://preview-*.example.com/callback) names no single origin.

  • A callback on an application you have deleted.

  • A plaintext http:// callback on a production application, which sign-in does not honour either.

  • A different port on a host you registered. The port is part of the origin.

A return_to we do not accept is dropped, not refused. The person is never shown an error and never stopped mid-flow: they finish exactly where they would have without the parameter — on the new organization's page, on the switcher, or on your tenant's root. A value we rejected is never written back into the page either, so a crafted one cannot be reflected at whoever opens the link.

It survives signing in. Somebody who follows one of these links signed out goes through sign-in and comes back to the page with the parameter intact, and an acceptance that needs authentication carries it through the same round trip.

We say what happened in the query when we hand the person back:

  • After creating: organization=<id>, the organization they just made.

  • After picking: organization=<id>, empty when they chose Personal.

  • After accepting an invitation: organization-invite=<accepted|invalid|expired|wrong-account>.

The active organization

A credential we issue acts in one organization at a time — the active one. That is what lets your application ask "may they do this here" without you threading an organization id through every call.

  • One membership auto-selects. Someone who belongs to exactly one organization never has to pick; it is resolved on every issuance, never written back.

  • Two or more select nothing until the person chooses. They pick on your tenant's own host at /organizations, where they can also act with no organization at all.

  • Your application can name one. Add organization to the authorization request — the organization's id or its slug — and the sign-in lands already acting there. This writes the session's pick, not just this credential's, so another application signing in on the same browser session sees the organization your request selected.

GET https://{your-tenant-host}/api/oauth2/authorize?…&organization=acme

To move an existing credential to another organization without sending the person back through sign-in, name it on a refresh request. Here it must be the organization's id — the refresh grant does not accept a slug:

TypeScript
async function tokenForOrganization(
  host: string,
  clientId: string,
  refreshToken: string,
  organizationId: string,
): Promise<{ access_token: string; refresh_token: string }> {
  const response = await fetch(`https://${host}/api/oauth2/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      client_id: clientId,
      refresh_token: refreshToken,
      organization_id: organizationId,
    }),
  });
  if (!response.ok) {
    throw new Error(`token request failed: ${response.status}`);
  }
  return await response.json() as {
    access_token: string;
    refresh_token: string;
  };
}

The request above authenticates as a public client. A confidential client sends its client_secret the same way it does on any other refresh.

organization_id is accepted on the refresh grant and nowhere else — sending it on any other grant is an invalid_request.

An unavailable organization refuses the exchange before anything rotates — whichever access-token format your application is configured for, and whether or not it requests the openid scope. The refresh token you presented is still good, so a retry without the parameter succeeds and the credential keeps the organization it already had. Confirm the organization is one the person belongs to — /api/memberships lists exactly those — before naming it.

The refusal is about the organization this request names. One the credential already carries that stops resolving — a membership revoked since the switch — does not refuse: the credential carries no organization from then on, so its organization claims drop rather than moving to another organization, and its refresh loop keeps working.

Every way an organization can be unavailable answers the same. Unknown, another tenant's, deleted, never a member, membership revoked — one refusal for all five, down to not echoing the id back, so probing cannot tell a tenant's organizations apart from ones that never existed. Expect the refusal, not a reason you can branch on.

What your application reads

The organization on the credential

The active organization reaches your application as three claims:

ClaimValue
org_idThe organization's id
org_slugIts stable handle — the thing to route on
org_rolesThe roles the subject holds there, unexpanded, exactly as /api/memberships reports them

They are stamped on the id_token, the UserInfo response, the introspection response, and — for applications configured for JWT access tokens — the access token itself. All four run the same resolution, so the claims mean the same thing wherever you read them. The organization's display name is deliberately not a claim: it changes, and a token is the wrong place to cache it. Read it from /api/memberships.

Every surface answers for the same organization, for the whole life of the credential. The organization is resolved once, when the credential is issued, and recorded on it: the id_token and a JWT access token carry that resolution in their signed bytes, and the introspection and UserInfo responses re-validate that organization on every call rather than re-deciding which organization it is. Introspecting the family's refresh token answers the same way. So switching organizations changes what the next credential is minted for and leaves an outstanding one alone, and a gateway that routes on org_id from the JWT while the service behind it authorizes on permissions from introspection is reading one organization on both sides.

All three are absent when no organization is active, and each is re-derived from live rows — at every issuance, and again on every introspection and UserInfo response — so a deleted organization or a revoked membership never leaves a stale organization on those two surfaces. Only what was already signed into an id_token or a JWT access token keeps what it was minted with until that credential expires or refreshes. When the credential's own organization stops resolving — deleted, or the membership revoked — introspection and UserInfo drop the organization claims rather than answering for whichever organization the login session has since picked, and the permissions they return narrow to what the subject holds tenant-wide. A revoked membership reads as "no organization", never as a different one.

Do not confuse org_roles with the separate roles claim, which carries the tenant-wide role slugs a subject holds everywhere. Mixing them would apply an organization's roles outside it.

The memberships the caller belongs to

Tokens deliberately carry the active organization, not the whole list. When you need the list — to render a switcher of your own — ask for it with the person's own access token, on your tenant's own host:

TypeScript
interface Membership {
  org_id: string;
  org_slug: string;
  name: string;
  roles: string[];
}

async function memberships(
  host: string,
  accessToken: string,
): Promise<Membership[]> {
  const found: Membership[] = [];
  let cursor: string | null = null;
  do {
    const url = new URL("/api/memberships", `https://${host}`);
    if (cursor) url.searchParams.set("cursor", cursor);
    const response = await fetch(url, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    if (!response.ok) {
      throw new Error(`memberships failed: ${response.status}`);
    }
    const page = await response.json() as {
      memberships: Membership[];
      cursor: string | null;
    };
    found.push(...page.memberships);
    cursor = page.cursor;
  } while (cursor);
  return found;
}

It answers for the caller and only the caller: there is no field naming somebody else, it needs no scope, and a machine (client_credentials) credential is refused — it holds no memberships. Accepted memberships only, up to 100 per page.

roles lists what is held, not what it implies. The tiers inherit when we resolve a check, but this array is unexpanded — an owner comes back as ["owner"] alone. A reader that asks roles.includes("admin") therefore denies the owner; compare against every tier that satisfies you, or ask /api/check and let the resolution do it.

Whether they may act here

The permissions claim on the credential already answers for the active organization, and on an introspection or UserInfo response it answers as of now. For any other organization — or a live answer for a reader holding a JWT access token, whose claims were fixed when it was signed — ask directly:

TypeScript
async function mayActInOrganization(
  host: string,
  accessToken: string,
  organizationId: string,
  permission: string,
): Promise<boolean> {
  const response = await fetch(`https://${host}/api/check`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${accessToken}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      permissions: permission,
      resource: { type: "organization", id: organizationId },
    }),
  });
  if (!response.ok) throw new Error(`check failed: ${response.status}`);
  const { results } = await response.json() as {
    results: Record<string, boolean>;
  };
  return results[permission] === true;
}

Permissions and roles covers the whole model — registering the capability strings, composing them into roles, and the three widths an assignment can have.

Roles inside an organization

An organization has its own role vocabulary, separate from the membership tiers above. Roles defined for the whole tenant can be held anywhere in it; roles defined on one organization's page apply only there, and the organization's own owner or admin composes them from the permissions you registered.

Both kinds resolve into the same permissions claim, so your application never has to know which kind someone holds. A role slug may not name one of the built-in tiers, or one of the access levels that govern the Udibo dashboard — a role of your customers' can never become authority over your tenant.

Manage the tenant-wide vocabulary in your dashboard under Access controlRoles, or through /api/identity/{tenantId}/organization-roles; an organization's own roles live under the Roles tab on its page.

Organizations inside your own application

Everything your users do on the hosted pages, your application can also do for them from inside your own product. The surface is on your tenant's own host:

https://{your-tenant-host}/api/organizations

It is the same organization surface the hosted pages drive — the same service calls, so the rows and the audit events are identical however the write arrives. The hosted pages stay exactly as they are; use either, or both.

It exists only on your tenant's own host. On Udibo's own host every one of these paths answers 404. The /api/identity/… endpoints are the separate administrator surface described below; do not point your application at those.

The credential is the signed-in person's own access token, issued by your tenant's authorization server — the same token your application already sends to /api/memberships and /api/check. No identity:* scope is required, and no dashboard access: a customer's end user holds neither and never will. Neither substitutes for authority either. What a caller may do is decided on every request by that caller's own accepted membership, so holding a scope changes nothing. A token your tenant did not issue, or no token at all, is a 401.

A machine (client_credentials) token is refused 403 on every verb, including create. A machine credential acts for an application rather than for a person, so it holds no memberships and there is nobody for it to act as. The refusal is the same on every path and lands before any organization is looked up, so it never tells you anything about an organization. Call this API with a person's token, and only with that.

What you can call, and what it takes

MethodPathAuthority
GET/api/organizationsAny signed-in person
POST/api/organizationsAny signed-in person
GET/api/organizations/{organizationId}Any accepted membership
PATCH/api/organizations/{organizationId}owner or admin
DELETE/api/organizations/{organizationId}owner
GET/api/organizations/{id}/membersAny accepted membership
DELETE/api/organizations/{id}/members/{userId}/{role}owner or admin
GET/api/organizations/{id}/member-rolesowner or admin
POST/api/organizations/{id}/invitationsowner or admin
GET/api/organizations/{id}/invitationsowner or admin
DELETE/api/organizations/{id}/invitations/{invitationId}owner or admin
GET/api/organizations/{id}/rolesowner or admin
POST/api/organizations/{id}/rolesowner or admin
GET/api/organizations/{id}/roles/{roleId}owner or admin
PATCH/api/organizations/{id}/roles/{roleId}owner or admin
DELETE/api/organizations/{id}/roles/{roleId}owner or admin

The three tiers of the membership ladder are the whole authority model here — never a scope, never a seat, never anything your application asserts about the caller:

  • Listing your own organizations and creating one need only a signed-in person. The creator becomes the new organization's accepted owner in the same transaction, so there is never an organization nobody manages.

  • Any accepted membership reads the organization and its member list.

  • An accepted owner or admin does everything else: rename, invite, withdraw an invitation, revoke a membership, and define, edit or retire the organization's own roles. Reading the invitation list, the invitation vocabulary (member-roles) and the organization's roles is manager-only too.

  • An owner deletes the organization, and is also required to offer or revoke the owner role itself.

Two rules carry over from the surfaces you already know, because it is the same code underneath: a delete is refused 409 while the organization still holds a live grant, and a role may only carry permissions your tenant has registered — an unregistered key is refused 400, naming each offending entry by index. Renaming a role is refused 409 while a grant still holds it, exactly as retiring it is.

Below the read tier there is one refusal, byte for byte. A malformed id, an unknown one, one belonging to another tenant, a deleted organization and one the caller simply is not a member of all answer the same 404, so this API cannot be used to learn which organizations exist. There is no reason in it to branch on. Only where the caller is already a known manager and the tier is the one thing missing is the refusal a 403 that says so: an admin deleting the organization, and an admin offering or revoking the owner role.

Nothing in the request names a tenant. The host does. A tenantId in the body or the query string is dropped rather than honoured, and the write lands in the tenant whose host you called.

Calling it from the browser

These endpoints answer any origin — Access-Control-Allow-Origin: *, and Access-Control-Allow-Credentials is never sent. That is safe here for one reason: no cookie authenticates anything on this surface. The only credential read is the Authorization header your own code attaches to that one request, so a wildcard hands another page nothing it could not already send for itself. A request carrying your tenant's session cookie and no bearer token is a 401.

What a browser in your application sends:

  • Authorization: Bearer <the person's access token> on every request.

  • Content-Type: application/json on a write. That makes it a preflighted request; the OPTIONS is answered before authentication, because a preflight carries no credential to authenticate.

  • Not credentials: "include". It buys nothing — no cookie is read, and without Access-Control-Allow-Credentials the browser would refuse to expose the response to you anyway.

TypeScript
export async function createOrganization(
  host: string,
  accessToken: string,
  input: { name: string; slug: string },
): Promise<{ id: string; slug: string; name: string }> {
  const response = await fetch(`https://${host}/api/organizations`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${accessToken}`,
      "content-type": "application/json",
    },
    body: JSON.stringify(input),
  });
  if (response.status !== 201) {
    throw new Error(`create organization failed: ${response.status}`);
  }
  return await response.json() as { id: string; slug: string; name: string };
}

An invitation posted here behaves exactly as inviting members describes: the address decides whether a pending membership or a pre-subject invitation is written, the response says which, and the invitee is emailed either way. Accepting is still ours — the invitee finishes at /organization-invite on your tenant's own host, and no API accepts an offer for them.

The administrator's API, and your application's

Two APIs manage organizations, and what separates them is who is calling:

  • /api/identity/{tenantId}/… is the administrator's surface — the endpoints listed under creating an organization. The credential that reaches them today is the one your dashboard session carries; a machine credential minted from an application in your own tenant is not accepted there. Use the dashboard or an approved administrator workflow, and keep administrator credentials out of your application.

  • /api/organizations on your tenant's own host is your application's surface — the same organizations, acting as the person signed in to your product, authorized by their membership rather than by authority over your tenant. This is the one to build your own organization screens on.

/api/identity/organizations is neither. It is the same caller-relative surface addressed on Udibo's own host, for people signed in to Udibo itself, and it answers 404 on your tenant's host. Your application never calls it.

Your users' own surfaces are unaffected by either: the hosted organization pages, /api/memberships and /api/check all run on your tenant's own host and authenticate the person's own credential.

Limits today

  • Flat only. No nested organizations, no groups inside one, and no domain capture that auto-joins an address.

  • No resend button. The invitation email goes out once, when the offer is created. To send another, withdraw the offer and make it again — which mints a new one and a new link, inside the hourly budgets above.

  • No delivery reporting. Nothing tells you whether an invitation email was sent, dropped for budget, or bounced; the response is the same either way.

  • Accepting happens on our page. Your application can create organizations, invite people and manage members for its signed-in user, but nothing accepts an offer on the invitee's behalf: they finish at /organization-invite on your tenant's own host.

  • A tier or role is never patched onto an existing membership. Remove and re-offer.

  • Organizations are not a tenancy boundary. They group your end users inside one tenant; they do not partition your data for you.

Last verified 2026-09-14.