Organizations
Use organizations when you sell 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.
Find your task
| You want to… | Start here |
|---|---|
| Build a company workspace after sign-up | Create an organization |
| Let customers bring their teammates | Invite members |
| Return people to your app after an invitation | Bring people back to your application |
| Let a person switch between companies | Select the active organization |
| Read memberships or protect an API request | What your application reads |
| Build your own organization-management screens | Organizations inside your own application |
For an end-to-end example, follow the B2B SaaS walkthrough. The sections below own the detailed contracts.
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, with at least one visible character and no line breaks, invisible characters or text-direction marks. Most emoji work, including country flags, skin tones and keycaps. Emoji built from several joined together, such as 👩💻, 🏳️🌈 or 🏴, do not |
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; keys and text values must contain valid Unicode and no NUL characters |
An organization's metadata is confidential from its members, whatever their
tier. Your administrators read and write it in the dashboard and through the
management API under /api/identity/{tenantId}/organizations. No response a
member receives carries it: not the hosted organization pages, not
/api/organizations on your tenant's host, and not the organization claims in
their tokens. Only your administrators write it, too: POST /api/organizations
and PATCH /api/organizations/{organizationId} refuse a body carrying
metadata with a 400 and write nothing, so a value there is one only your
administrators could have set.
Those two calls refuse any field they do not recognize, not only metadata.
Send name and slug alone: an organization you fetched and send back whole,
with its id, createdAt, updatedAt or deletedAt, is refused with a 400
and nothing changes.
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
The three built-in membership tiers 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 |
Invitations can also offer a tenant-wide custom role from your role
registry. A custom membership role matches only its own slug; it does not
satisfy a built-in member, admin or owner check. Accepting that offer does
not assign the permissions in the custom role's definition. Assign application
access separately through App roles below; an organization-held resource
grant can also confer access through accepted membership, as described in
permissions and roles.
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.
To assign that application access in the dashboard, open Directory → Organizations → the organization → Members, then choose App roles for a member who has accepted. The assignment page names the organization so you can distinguish this access from tenant-wide roles. Follow assign a role for the steps and required authority. Your customers can manage access through screens you build with the caller-relative API below.
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 application creates one for its signed-in user — build this. 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 one write. It takes no identity:* scope and no dashboard
credential — see
organizations inside your own application
below. This is the path to put a self-serve B2B product on: the first person
from a company signs up, creates their company on a screen you own, and invites
their colleagues.
You create one for them. In your dashboard, Directory → Organizations → Create organization. The management API operations are listed for reference; see the note under the administrator's 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.
Who may create organizations
By default every signed-in user of your tenant may create an organization from
your application through POST /api/organizations. A tenant can close that: in
your dashboard, Tenant → Settings → General → Organizations, set
Who may create organizations to Only your team. The field is also a
management API setting, listed for reference:
| Field | Values | Default | Method | Path | Scope |
|---|---|---|---|---|---|
organizationPolicy.creation | anyone, none | anyone | PATCH | /api/identity/tenants/{id} | identity:tenants:write |
anyone— every signed-in end user of the tenant may create an organization and becomes its owner: the posture every tenant has until it chooses otherwise.none— only your administrators create organizations. The self-serve create is refused on every surface identically: the API answers 403 withreason: organization_creation_disabledand writes nothing, and on Udibo's own host — the only host that serves them — the/organizations/newpage explains instead of offering the form and the/organizationslist drops its create button. The action behind the page refuses on its own, so hiding the form is not what keeps people out.You are never governed by it. Creating an organization for your users from the dashboard or through
POST /api/identity/{tenantId}/organizationsworks whatever the setting.Organizations that already exist, their members, invitations and roles are untouched. This is a switch on who starts a new organization, not a limit on how many there are — organizations stay unlimited on every plan.
Send
{ "organizationPolicy": { "creation": "none" } }in the PATCH body. Like the other policy objects it merges, so a switch you omit keeps its value; a value outside the two answers 400 namingorganizationPolicy.creation.
Inviting members
Two surfaces make the offer, and both write the same rows and the same audit events:
Your application, acting as an
owneroradminsigned in to it — build this.POST https://{your-tenant-host}/api/organizations/{organizationId}/invitationswith that person's own access token, described in organizations inside your own application.You, in the dashboard under Directory → Organizations.
An offer names an email address and a tier or role:
{ "email": "ada@example.com", "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 — or inside your own application, once you
have given your tenant a home URL and built the landing page described under
rendering the invitation landing in your application.
The emailed link opens that one invitation. Its invitee sees who invited them (the inviter's name, when a person of your tenant sent it), the organization, the role, and when it expires, with Accept invitation and Decline. Both are plain forms, so the page works without JavaScript. Once the invitation can no longer be accepted, the page says why instead: it has expired, it was already accepted or declined, the organization withdrew it, or the organization no longer exists. The page describes an invitation only to the person it was sent to:
Signed in as a different account, the page says only that the invitation is for someone else, names nothing about it, and offers Switch account, which signs them out and back in and returns them to the same invitation.
Signed in with the invited address unverified, it asks them to verify the address first and names nothing about it.
Signed out, they go to your sign-in page and come back to the invitation afterwards, including through creating an account, verifying its address, or resetting a forgotten password.
Declining is the invitee's own decision and is audited as theirs. It frees
the address to be invited to that role again, and an invitee who declines twice
changes nothing the second time. A declined invitation is recorded as
admin.organization.invitation.declined, and a declined pending membership as
admin.organization.member.declined, each attributed to the invitee; a webhook
subscribed to them receives them like any other event. Nobody is emailed.
Declining is available on the hosted invitation page only: the
offers API lists and accepts offers but
has no decline.
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 — or your application's home URL carrying
the offer's id, when you have set one. 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.
Removing someone's last membership removes them, and ends what membership gave them there. In the same step, Udibo:
takes away every application role granted to them inside that organization (their tenant-wide roles and their grants on individual resources stay);
withdraws every offer still open to them there — pending memberships, and invitations to their address — including an
owneroffer the person removing them could not have withdrawn themselves.
Each of these is recorded in the audit log as its own event — an application
role as admin.user.role_revoked, a withdrawn offer as
admin.organization.member.revoked or admin.organization.invitation.revoked —
naming who removed them and carrying reason: "membership_ended". If they are
invited back later, they hold only what the new invitation offers; grant any
application role again after they accept.
Bringing people back to your application
The one page your users act on — accept at /organization-invite — takes 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}/organization-invite?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 — your tenant's home, per the landing order below —
with the outcome appended. 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.
An invitation your application makes can carry one too. Put return_to in
the body of POST /api/organizations/{organizationId}/invitations and the
invitee is brought back to your application after accepting, with the outcome
appended as above. Because an API caller can act on a refusal where a person
mid-flow cannot, this one is validated when you write it against the same
allowlist and refused with a 400 naming return_to in fieldErrors rather
than dropped — an offer never stores a destination we would not honour. The
accepted value is kept on the offer and put in the emailed link, and it is
checked again when the invitation is accepted: delete the application whose
origin it named in between, and the invitee lands on the ordinary fallback
instead.
We say what happened in the query when we hand the person back:
organization-invite=<accepted|invalid|expired|wrong-account>.
Where an accepted invitation lands when nothing asked for the person back
A return_to names where a specific request should end. Most invitees carry
none — they followed an email — so the tenant needs a standing answer, and it
has two. Set a home URL under Settings → Branding, or as
branding.homeUrl on PATCH /api/identity/tenants/{id} (the same rule applies
on both, and the API reference documents the other branding fields): your own
application, where a signed-in person goes when nothing asked for them back. It
is accepted only when it is on the origin of a redirect URI registered on one of
your live applications — the same allowlist return_to runs — and refused
otherwise, so it can never be an open redirect; it is checked again each time it
is used, so deleting that application makes us fall back rather than keep
sending people there. Leave it blank and we guess: the origin of your oldest
first-party application with a registered redirect URI.
The destination is consulted in this order, and the first that answers wins:
The request's own validated
return_to.Your home URL.
The origin of your oldest registered first-party application.
/profileon your tenant's host, when you have registered nothing at all.
Every one but the last has the outcome appended
(organization-invite=<status>). The same home is where a sign-in on your
tenant's host lands when it named no continuation, and where the "Back to
{name}" button on our hosted account pages points. It does not change where a
support impersonation session lands: your first-party application's origin, or a
status page when there is none.
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, and the choice is made from your application — on the authorization request or the refresh grant, as below. A person may 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. It scopes this credential and nothing else: the browser session's own pick is untouched, so another application signing in on the same browser session gets what the person picked, not what your link named.Which organization a credential gets, in order. The one your request names; else the one the person picked on our switcher, where picking Personal means none; else, for someone in exactly one organization who has picked nothing, that one. A sign-in link is a GET any page can start, so no request of yours moves the pick that outlives one credential — only the switcher does, from a same-origin form. The switcher (
/organizations) serves Udibo's own site only and a customer tenant's host redirects it there, so for your users the order is in practice the organization your request names, then their sole membership.
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 |
All three are stamped on the UserInfo response, the introspection response, and
— for applications configured for JWT access tokens — the access token itself.
The id_token carries org_id and org_slug but not org_roles: roles are
authorization, which the id_token leaves out so its size stays bounded, so
read org_roles from introspection or UserInfo. Earlier releases put org_roles
in the id_token too. Every surface runs 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. The slugs owner, admin and member
are reserved for the built-in membership tiers. Other customer role slugs,
including support, do not confer Udibo dashboard authority; dashboard access
remains separate from application roles.
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 with an organization, your application does 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 dashboard drives — the same service
calls, so the rows and the audit events are identical however the write arrives.
There is no hosted organization page on your tenant's host to fall back on:
/organizations and everything beneath it redirect to Udibo's own site there,
so your users create, switch, invite and manage from screens you own, and meet a
page of ours only to accept an invitation.
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/offers | The invitee themselves |
POST | /api/organizations/offers/{offerId}/accept | The invitee themselves |
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 |
POST | /api/organizations/{id}/members/{userId}/roles | owner or admin |
DELETE | /api/organizations/{id}/members/{userId}/roles/{roleId} | owner or admin |
GET | /api/organizations/{id}/members/{userId}/roles | 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. The organization comes back without its
metadata, whatever the caller's tier; see the shape.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.
Granting a member who has already accepted an application role — a tenant-wide
one, or one of this organization's own — through members/{userId}/roles is
manager-only too and takes effect without another invitation. A pending member,
a non-member and a role this organization cannot see all answer the same 404,
and revoking a built-in role held here additionally requires holding at least
that rank here. The ids the grant takes come from two reads: member-roles
names each tenant-wide role's id beside its slug and name (the built-in tiers
carry no id, because they are constants rather than rows), and GET …/roles
lists this organization's own. GET …/members/{userId}/roles answers the
application roles a member holds here — each with its id, slug, name,
permissions and, for one of this organization's own roles, its
organizationId — so a screen can show what it would revoke. It is manager-only
like the grant, and a pending member, a non-member and an unknown id answer the
same 404 the grant does.
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 the invitee's own
call, made with their own token — through our hosted /organization-invite
page, or through the two offer verbs below from a landing page you build.
Rendering the invitation landing in your application
By default the invitation email opens our hosted /organization-invite page on
your tenant's host. Give your tenant a home URL (Settings → Branding, or
branding.homeUrl on PATCH /api/identity/tenants/{id}) and the email opens
your application instead, so the invitee never leaves your product:
{homeUrl}?organization-invite=<offerId>
{homeUrl}?organization-invite=<offerId>&return_to=<the inviter's return_to>The page at that URL is yours to build, and it needs three things:
Read
organization-inviteoff the query. A uuid is an offer to accept. Anything else (accepted,invalid,expired,wrong-account) is an outcome our hosted page is reporting back to you — someone used the hosted page with noreturn_to, and it landed on your home URL as described above — so show the result and stop.Sign the person in, then list what is waiting for them:
GET /api/organizations/offerswith their access token. It answers exactly what the hosted page lists — invitations to the caller's verified email address plus the pending grants they are the subject of, each with its organization's name and a readable role — andunverifiedEmailnames the caller's address while it is unverified, in which case invitations to it are hidden until they verify it. An offer whose organization has since been deleted is omitted.Accept the one the link named:
POST /api/organizations/offers/{offerId}/accept, same token, no body. The answer is always a200whosestatussays what happened:
status | Meaning |
|---|---|
accepted | The offer converted; membership is the row it became |
wrong-account | The caller holds a different address than the one invited, or holds it unverified |
expired | The invitation aged out |
invalid | No open offer with that id for this person, already spent, organization no longer live |
These are outcomes to show the person, not errors, and the hosted page treats
them the same way, so a malformed or unknown id is invalid rather than a 404.
Every rule the hosted page enforces holds here, because the API runs the same
functions it runs: an invitation converts only for the verified holder of its
address, a pending grant only for its own subject, either only into a live
organization, and a refused offer is left unspent. Holding the id is never
enough. A machine token is refused 403 on both verbs.
return_to on the link is data for you, not an instruction. It is the
destination the inviter asked the invitee to end up at, validated against your
registered redirect URIs once, when the invitation was made, and carried
unchanged. On our hosted page it is checked again at acceptance; on yours it is
not — the accept verb never sees it — so treat it as a hint you validate
yourself before following (your own origin, or an origin you trust), or ignore
it. Never redirect to it unchecked.
The hosted page keeps working with a home URL set: an old email, or a link you
build yourself to /organization-invite?id=… on your tenant's host, still opens
it.
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. They accept only the dashboard's own session or a token from Udibo's own sign-in, which you cannot get outside the dashboard; a token your tenant issues, including a client-credentials token, is refused with401. Use the dashboard; see the management API./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: /organization-invite,
/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.
Nothing accepts an offer on the invitee's behalf. Your application can render the landing page and accept with the invitee's own token, but no administrator or machine credential can accept for them. Where your application renders no landing, the invitee finishes at
/organization-inviteon your tenant's own host — the one organization page a customer's users meet there; the management pages serve Udibo's own host only.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-10-29.

