# 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](https://www.udibo.com/docs/identity/organizations#creating-an-organization) | | Let customers bring their teammates | [Invite members](https://www.udibo.com/docs/identity/organizations#inviting-members) | | Return people to your app after an invitation | [Bring people back to your application](https://www.udibo.com/docs/identity/organizations#bringing-people-back-to-your-application) | | Let a person switch between companies | [Select the active organization](https://www.udibo.com/docs/identity/organizations#the-active-organization) | | Read memberships or protect an API request | [What your application reads](https://www.udibo.com/docs/identity/organizations#what-your-application-reads) | | Build your own organization-management screens | [Organizations inside your own application](https://www.udibo.com/docs/identity/organizations#organizations-inside-your-own-application) | For an end-to-end example, follow the [B2B SaaS walkthrough](https://www.udibo.com/docs/identity/b2b-saas-template). 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](https://www.udibo.com/docs/identity/permissions#3-assign-a-role-to-someone). 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](https://www.udibo.com/docs/identity/permissions). 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](https://www.udibo.com/docs/identity/permissions#3-assign-a-role-to-someone) 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](https://www.udibo.com/docs/identity/organizations#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](https://www.udibo.com/docs/identity/organizations#the-administrators-api-and-your-applications): | 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** with `reason: organization_creation_disabled` and writes nothing, and on Udibo's own host — the only host that serves them — the `/organizations/new` page explains instead of offering the form and the `/organizations` list 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}/organizations` works 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 naming `organizationPolicy.creation`. ## Inviting members **Two surfaces make the offer**, and both write the same rows and the same audit events: - **Your application, acting as an `owner` or `admin` signed in to it — build this.** `POST https://{your-tenant-host}/api/organizations/{organizationId}/invitations` with that person's own access token, described in [organizations inside your own application](https://www.udibo.com/docs/identity/organizations#organizations-inside-your-own-application). - **You, in the dashboard** under **Directory** → **Organizations**. An offer names an email address and a tier or role: ```json { "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](https://www.udibo.com/docs/identity/organizations#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](https://www.udibo.com/docs/identity/organizations#what-you-can-call-and-what-it-takes) 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](https://www.udibo.com/docs/identity/waitlist#approve-and-re-invite) 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 `owner` offer 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%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 — 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=`. ### 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: 1. The request's own validated `return_to`. 2. Your home URL. 3. The origin of your oldest registered first-party application. 4. `/profile` on your tenant's host, when you have registered nothing at all. Every one but the last has the outcome appended (`organization-invite=`). 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 `organization` to 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=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: ```ts 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: ```ts interface Membership { org_id: string; org_slug: string; name: string; roles: string[]; } async function memberships( host: string, accessToken: string, ): Promise { 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: ```ts async function mayActInOrganization( host: string, accessToken: string, organizationId: string, permission: string, ): Promise { 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; }; return results[permission] === true; } ``` [Permissions and roles](https://www.udibo.com/docs/identity/permissions) 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/organizations ``` It 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](https://www.udibo.com/docs/identity/organizations#the-administrators-api-and-your-applications); 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](https://www.udibo.com/docs/identity/organizations#membership-tiers) 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. The organization comes back without its `metadata`, whatever the caller's tier; see [the shape](https://www.udibo.com/docs/identity/organizations#the-shape). - **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. 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](https://www.udibo.com/docs/identity/organizations#creating-an-organization), 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 ` 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. ```ts 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](https://www.udibo.com/docs/identity/organizations#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= {homeUrl}?organization-invite=&return_to= ``` The page at that URL is yours to build, and it needs three things: 1. **Read `organization-invite` off 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 no `return_to`, and it landed on your home URL as described above — so show the result and stop. 2. **Sign the person in, then list what is waiting for them:** `GET /api/organizations/offers` with 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 — and `unverifiedEmail` names 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. 3. **Accept the one the link named:** `POST /api/organizations/offers/{offerId}/accept`, same token, no body. The answer is always a `200` whose `status` says 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](https://www.udibo.com/docs/identity/organizations#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 with `401`. Use the dashboard; see [the management API](https://www.udibo.com/docs/identity/apis#the-management-api). - **`/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: `/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-invite` on 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._