<!-- section: Auth API · status: building · source: docs/auth-api/09-organizations.md -->

> Workspaces inside your app — create them, set roles, invite people, and fill in org_id.


# Organizations

Workspaces inside *your* app — a company, a team, a tenant of yours. Not to be
confused with your own xplatform organization; these live in your project's
database and we never see them.

## POST /v1/auth/organization/create

**Publishable** key and a session cookie.

```bash Request
curl -X POST "$BASE/v1/auth/organization/create" \
  -H "authorization: Bearer $PK" \
  -H "content-type: application/json" \
  -b "session cookie" \
  -d '{"name":"Acme Engineering","slug":"acme-eng"}'
```

Whoever creates one is its `owner`, and it becomes their active organization.

## The roles

| | organization | members | invitations |
|---|---|---|---|
| `owner` | update, delete | add, update, remove | send, cancel |
| `admin` | update | add, update, remove | send, cancel |
| `member` | read only | read only | read only |

A `member` attempting any of it gets a **403**, which we checked rather than
assumed.

## It fills in `org_id`

The JWT has carried `org_id` and `role` since the first day this service existed,
and until organizations shipped both were always null. That was deliberate — a
consumer written against those claims last month keeps working today without
touching their code, which is the thing that bites teams who add the claim
afterwards.

```json
{ "sub": "Nrzmwy…", "org_id": "2kbhx7…", "role": "owner", "sid": "…" }
```

`org_id` is the **active** organization on the session, because one person can
belong to several and switch without their identity changing. Set it with
`POST /v1/auth/organization/set-active`.

`role` is read from the membership at the moment the token is minted, not copied
onto the session at sign-in — so demoting an admin takes effect on their next
token rather than their next sign-in.

## POST /v1/auth/organization/invite-member

```bash Request
curl -X POST "$BASE/v1/auth/organization/invite-member" \
  -H "authorization: Bearer $PK" \
  -H "content-type: application/json" \
  -b "session cookie" \
  -d '{"email":"grace@example.com","role":"member"}'
```

**The page it lands on is yours.** We deliver the mail; your app renders the
accept screen and calls `accept-invitation`. Point it somewhere with the
`invitationUrl` setting; the invitation id is appended:

```
invitationUrl = https://app.acme.com/join   →   https://app.acme.com/join/<id>
```

Unset, it guesses at `/accept-invitation` on your first registered origin, so
invitations work before you have configured anything.

**An invitation whose email fails still returns 200.** The row is written before
the mail is attempted, so a bounced or refused message does not roll it back.
`GET /v1/auth/organization/list-invitations` gives you the id, and the link is
your URL plus that id — so a re-send is always possible without creating a
second invitation.

## Errors

| Status | Code | Means |
|---|---|---|
| `403` | — | A `member` attempting an owner or admin action |
| `400` | `ORGANIZATION_ALREADY_EXISTS` | That slug is taken within your project |
| `404` | — | No such organization, or the caller is not in it |
