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:
| Field | Rules |
|---|---|
name | What people see. 1–100 characters |
slug | A stable handle within the tenant: lowercase letters, digits and single hyphens, 1–100 chars |
metadata | A 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:
| Tier | What it means |
|---|---|
owner | Full control of the organization, including its members, roles and settings |
admin | Manages the organization's members and settings |
member | Belongs 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, Directory → Organizations → Create organization, or through the management API:
| Method | Path | Scope |
|---|---|---|
POST | /api/identity/{tenantId}/organizations | identity:organizations:write |
GET | /api/identity/{tenantId}/organizations | identity: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 Directory → Organizations, and
your application, acting as an owner or admin signed in to it —
POST 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:
{ "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.
Accepting is bound to the person, never to the link
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%2FteamsWhere 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 aproductionapplication, 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
organizationto 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=acmeTo 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:
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:
| Claim | Value |
|---|---|
org_id | The organization's id |
org_slug | Its stable handle — the thing to route on |
org_roles | The 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:
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:
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 control →
Roles, 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/organizationsIt 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
| Method | Path | Authority |
|---|---|---|
GET | /api/organizations | Any signed-in person |
POST | /api/organizations | Any 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}/members | Any accepted membership |
DELETE | /api/organizations/{id}/members/{userId}/{role} | owner or admin |
GET | /api/organizations/{id}/member-roles | owner or admin |
POST | /api/organizations/{id}/invitations | owner or admin |
GET | /api/organizations/{id}/invitations | owner or admin |
DELETE | /api/organizations/{id}/invitations/{invitationId} | owner or admin |
GET | /api/organizations/{id}/roles | owner or admin |
POST | /api/organizations/{id}/roles | owner 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
ownerin the same transaction, so there is never an organization nobody manages.Any accepted membership reads the organization and its member list.
An accepted
owneroradmindoes 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
ownerdeletes the organization, and is also required to offer or revoke theownerrole 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/jsonon a write. That makes it a preflighted request; theOPTIONSis answered before authentication, because a preflight carries no credential to authenticate.Not
credentials: "include". It buys nothing — no cookie is read, and withoutAccess-Control-Allow-Credentialsthe browser would refuse to expose the response to you anyway.
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/organizationson 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-inviteon 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.

