Endpoints: Organizations, Teams & Invitations
Organizations
Section titled “Organizations”POST /orgs
Section titled “POST /orgs”Create an additional organization owned (role: owner) by the calling user. Requires a
human session — API keys are already bound to one org and can’t create another. Also 403s if
a platform admin has turned off registration.allow_public_org_creation, unless the caller is a
superadmin — see Configuration Reference — registration.
Request body — application/json
{"name": "Acme Corp"}Response 201
{"org_id": "8f2a...", "name": "Acme Corp", "role": "owner"}The three built-in report templates and every analyzer’s built-in rules are seeded for the new org automatically, same as at registration.
| Status | Description |
|---|---|
201 | Organization created |
403 | Caller is an API key, not a human session; or public organization creation is disabled and the caller isn’t a superadmin |
GET /orgs/{org_id}
Section titled “GET /orgs/{org_id}”Fetch one organization’s info. Requires membership in that org.
Response 200
{ "org_id": "8f2a...", "name": "Acme Corp", "plan_tier": "free", "quota": {"monthly_budget_usd": 50.0}, "report_link_ttl_hours": 24, "invite_link_ttl_hours": 168, "created_at": "2026-01-01T00:00:00Z"}| Status | Description |
|---|---|
200 | Organization info |
403 | Caller does not belong to org_id |
404 | Organization not found |
GET /orgs/{org_id}/members
Section titled “GET /orgs/{org_id}/members”List every member of the org (name, email, role, org-scoped active status, joined date, and the names of any teams they’re on). Any member role can call this.
Response 200
{ "members": [ { "is_active": true, "account_active": true, "created_at": "2026-01-01T00:00:00Z", "teams": [] }, { "is_active": true, "account_active": true, "created_at": "2026-01-02T00:00:00Z", "teams": ["Platform", "Security"] } ]}name is null until the user sets one via PATCH /auth/me. is_active is
org-scoped (see PATCH /orgs/{org_id}/members/{user_id}
below) — false here doesn’t mean the account itself is deactivated everywhere, only in this
org. account_active is the whole-account User.is_active — only a platform admin can change
it (PATCH /admin/users/{user_id}), and false here blocks login entirely, in every org, unlike
is_active. teams is populated from team_members, not stored on the membership
itself — a member can be on any number of teams, or none.
POST /orgs/{org_id}/members
Section titled “POST /orgs/{org_id}/members”Add an already-registered user to the org directly, at a given role. Requires
admin role. To bring in someone who hasn’t registered yet, use
POST /orgs/{org_id}/invites instead — this endpoint 404s if the email
isn’t a known account.
Request body
Response 201
{"user_id": "u2...", "org_id": "8f2a...", "role": "member"}| Status | Description |
|---|---|
201 | Member added |
403 | Caller does not have admin (or owner) role |
404 | No registered user with that email |
409 | User is already a member of this org |
PATCH /orgs/{org_id}/members/{user_id}
Section titled “PATCH /orgs/{org_id}/members/{user_id}”Change a member’s role and/or org-scoped active status. Requires admin role. Both fields optional — send just the one you’re changing.
role can never be set to owner here — transfer ownership directly instead (same rule as
invitation links and SSO’s default_role). is_active is org-scoped: deactivating a member
here blocks their access to this org only, on their very next request (enforced in
require_principal, not just at login) — it leaves their access to any other org they belong to,
and their account-wide User.is_active, untouched. See
PATCH /admin/users/{user_id} for the account-wide equivalent.
Cannot be used on the caller’s own membership, or to demote the org’s only remaining owner.
Request body
{"role": "admin", "is_active": false}Response 200 — the updated membership, same shape as an entry in
GET /orgs/{org_id}/members.
| Status | Description |
|---|---|
200 | Membership updated |
400 | Targets the caller’s own membership |
403 | Caller does not have admin (or owner) role |
404 | user_id is not a member of this org |
409 | Would demote/deactivate the org’s only remaining owner |
422 | role set to owner |
DELETE /orgs/{org_id}/members/{user_id}
Section titled “DELETE /orgs/{org_id}/members/{user_id}”Remove a member from the org entirely. Requires admin role. Same self/sole-owner
guardrails as the PATCH above.
| Status | Description |
|---|---|
204 | Member removed |
400 | Targets the caller’s own membership |
403 | Caller does not have admin (or owner) role |
404 | user_id is not a member of this org |
409 | Target is the org’s only remaining owner |
POST /orgs/{org_id}/leave
Section titled “POST /orgs/{org_id}/leave”Remove your own membership. Any role may leave — the deliberate counterpart to the
admin-only DELETE above, which refuses to act on the caller’s own membership.
Requires a human session; an API key is bound to one org for its whole life and has no membership to give up.
Response 200
{"left_org_id": "8f2a...", "org_id": "3b17...", "role": "member"}org_id/role are null when the org just left was the last one.
| Status | Description |
|---|---|
200 | Left; session re-scoped to org_id (or to no org) |
403 | API-key principal, or not a member of this org |
404 | Path org_id isn’t the caller’s current org |
409 | Caller is the org’s only remaining owner |
An organization always needs an owner, so its only owner cannot leave: transfer ownership first, or have a platform admin delete the organization. A membership provisioned by SCIM or SSO can be left like any other, but the IdP re-creates it on the next sync — remove the user there instead.
POST /orgs/{org_id}/members/{user_id}/reset-password
Section titled “POST /orgs/{org_id}/members/{user_id}/reset-password”Reset a member’s password. Requires admin role. Generates a new random password
server-side and returns it once — same one-time-reveal pattern as
POST /orgs/{org_id}/invites’s token and POST /auth/keys’s raw key. It is
never stored in plaintext and cannot be retrieved again after this response; relay it to the
member out of band. Not applicable to SSO-backed accounts.
Response 200
{"temporary_password": "kX9Qy2z..."}| Status | Description |
|---|---|
200 | Password reset — temporary_password shown once |
403 | Caller does not have admin (or owner) role |
404 | user_id is not a member of this org |
400 | Target account authenticates via SSO |
POST /orgs/{org_id}/members/{user_id}/reset-2fa
Section titled “POST /orgs/{org_id}/members/{user_id}/reset-2fa”Disable a member’s two-factor authentication. Requires admin role. Clears the member’s TOTP secret and recovery codes — they’ll need to enroll again from scratch. Use when a member has lost their authenticator device and their recovery codes.
Response 200
{"detail": "Two-factor authentication reset"}| Status | Description |
|---|---|
200 | 2FA reset |
403 | Caller does not have admin (or owner) role |
404 | user_id is not a member of this org |
Org-owned, freely-editable named groupings of existing members — e.g. “Platform” or “Security”. Pure organizational grouping: a team has no access-control effect at all — every org member still sees the same org-wide resources, custom rules, and findings regardless of team. A user can belong to any number of teams. Requires admin role to create/rename/delete a team or add/remove its members; any member can view.
team_id is a slug derived from name at creation and is immutable afterward — same
pattern as rule_id/category_id/template_id. No name-uniqueness is enforced: two teams
can both be named “Security”, with ids security/security-2.
POST /orgs/{org_id}/teams
Section titled “POST /orgs/{org_id}/teams”Requires admin role.
Request body
{"name": "Platform"}Response 201
{ "team_id": "platform", "org_id": "8f2a...", "name": "Platform", "created_by": "u1...", "created_at": "2026-01-01T00:00:00Z", "updated_at": null}GET /orgs/{org_id}/teams
Section titled “GET /orgs/{org_id}/teams”Any member. Response: {"teams": [...]}, each shaped like the create response above.
GET /orgs/{org_id}/teams/{team_id}
Section titled “GET /orgs/{org_id}/teams/{team_id}”Any member. 404 if not found.
PATCH /orgs/{org_id}/teams/{team_id}
Section titled “PATCH /orgs/{org_id}/teams/{team_id}”Requires admin role. Partial update — currently just name (team_id is immutable).
DELETE /orgs/{org_id}/teams/{team_id}
Section titled “DELETE /orgs/{org_id}/teams/{team_id}”Requires admin role. Cascades: every team_members row for this team is removed too
— unlike a rule category (which has an “Uncategorized” fallback for
orphaned references), a team has no equivalent fallback for its members, so cascading is the
only sane option. Does not touch the members’ own org membership.
GET /orgs/{org_id}/teams/{team_id}/members
Section titled “GET /orgs/{org_id}/teams/{team_id}/members”Any member. Response: {"members": [{"user_id", "email", "added_at"}, ...]}.
POST /orgs/{org_id}/teams/{team_id}/members
Section titled “POST /orgs/{org_id}/teams/{team_id}/members”Requires admin role. user_id must already be a member of this org — see
GET /orgs/{org_id}/members.
Request body
{"user_id": "u2..."}| Status | Description |
|---|---|
201 | Added |
404 | Team not found, or user_id isn’t a member of this org |
409 | User is already on this team |
DELETE /orgs/{org_id}/teams/{team_id}/members/{user_id}
Section titled “DELETE /orgs/{org_id}/teams/{team_id}/members/{user_id}”Requires admin role. Removes the user from this team only — their organization
membership is untouched. 404 if they weren’t on this team.
Invitations
Section titled “Invitations”Reusable, expiring, shareable org-join links — same capability-token design as report
links above (generate_api_key() for the raw token, only its SHA-256 stored server-side,
raw token returned exactly once). Unlike POST /orgs/{org_id}/members, the invitee
doesn’t need an account yet — opening the link lets them register or log in and land
directly in the org.
Open vs addressed
Section titled “Open vs addressed”An invitation created without an email is open: a bearer link, redeemable by whoever
holds it, discoverable by nobody. That was the only kind for a long time, and it is unchanged.
Passing an email makes it addressed, which changes two things:
- Only that account can redeem it. Holding the link is no longer enough —
POST /invite/{token}/accept403s for anyone signed in as a different address. - The recipient can find it without the link. It appears on their
GET /auth/me/invitations, which is the point: this deployment sends no email, so an open invite that never physically reaches someone may as well not exist.
POST /orgs/{org_id}/invites
Section titled “POST /orgs/{org_id}/invites”Requires admin role. Mints a new invite, valid for the org’s invite_link_ttl_hours
(default 168h/7 days, 1–8760 range — no dedicated PATCH to change it yet, unlike report
links’ TTL).
Request body
email is optional — see Open vs addressed above. It is stored
lower-cased, and matched case-insensitively.
Response 201 — token/join_url shown once, never retrievable again
{ "invite_id": "3f4a5b6c-...", "token": "xK9mZpQ...", "join_url": "https://.../invite/xK9mZpQ...", "role": "member", "created_at": "2026-01-01T00:00:00Z", "expires_at": "2026-01-08T00:00:00Z"}For an addressed invitation the link is a convenience, not the delivery mechanism — the recipient already has it on their own account.
| Status | Description |
|---|---|
201 | Invite created — save the join_url now |
403 | Caller does not have admin role |
422 | role was owner |
GET /orgs/{org_id}/invites
Section titled “GET /orgs/{org_id}/invites”Requires admin role. Lists every invite ever created for the org, revoked or expired included (the dashboard grays those out rather than hiding them). Never includes the token.
Response 200
{ "invites": [ { "invite_id": "3f4a5b6c-...", "role": "member", "created_by": "user-id-of-creator", "created_at": "2026-01-01T00:00:00Z", "expires_at": "2026-01-08T00:00:00Z", "revoked": false } ]}DELETE /orgs/{org_id}/invites/{invite_id}
Section titled “DELETE /orgs/{org_id}/invites/{invite_id}”Requires admin role. Stops the link from being used again — memberships already created through it are unaffected.
| Status | Description |
|---|---|
204 | Invite revoked |
403 | Caller does not have admin role |
404 | Invite ID not found |
GET /invite/{token}
Section titled “GET /invite/{token}”Public, no authentication — lets an accept-invite page render “Join (org name) as (role)” before the visitor logs in or registers.
Response 200
{"org_name": "Acme Corp", "role": "member", "expires_at": "2026-01-08T00:00:00Z", "addressed": true}addressed says whether the invitation names a specific email, so the page can warn that
signing in as anyone else will be refused. A bool, never the address itself: this endpoint is
unauthenticated, and whoever holds the link has not proved they are the intended recipient.
Deliberately omits org_id. 404 for not-found, expired, and revoked alike — same
“never distinguish why” contract as GET /reports/{token}.
POST /invite/{token}/accept
Section titled “POST /invite/{token}/accept”Requires an authenticated human session (not an API key). Joins the invite’s org at its role.
Response 200
{"org_id": "org-id", "role": "member"}| Status | Description |
|---|---|
200 | Joined (or already a member — role unchanged) |
403 | The invitation is addressed to a different email |
401 | No session, or an API-key principal |
404 | Token unknown, expired, or revoked |