Organization
Covers 14 API operations in the orgs category.
| Method | Path | Description |
|---|---|---|
| GET | /v1/orgs | — |
| POST | /v1/orgs | Create an org |
| PATCH | /v1/orgs/{orgSlug} | Rename an org's display name (runlot org rename) |
| DELETE | /v1/orgs/{orgSlug} | Delete an org (runlot org delete) |
| GET | /v1/orgs/{orgSlug}/notifications | Recent notifications (docs/limits.md §3.4) |
| GET | /v1/orgs/{orgSlug}/members | Org member (runlot org members) |
| POST | /v1/orgs/{orgSlug}/members | Add an existing user as a member (for ops and automation) |
| GET | /v1/orgs/{orgSlug}/invites | Pending invites (runlot member invites) |
| POST | /v1/orgs/{orgSlug}/invites | Invite by email (runlot member invite) |
| DELETE | /v1/orgs/{orgSlug}/invites/{inviteId} | Revoke an invite (runlot member uninvite) |
| GET | /v1/invites/{token} | Preview an invite (no authentication) |
| POST | /v1/invites/{token}/accept | Accept an invite |
| PUT | /v1/orgs/{orgSlug}/members/{userId} | Change a role (runlot org set-role) |
| DELETE | /v1/orgs/{orgSlug}/members/{userId} | Remove a member or leave on your own (runlot member rm, runlot member leave) |
GET /v1/orgs
operationId listOrgs
| Status code | Description | Response body |
|---|---|---|
| 200 | The owning organization | object |
| 401 | — | — |
POST /v1/orgs
The creator becomes admin. Personal accounts are also modeled as a one-person
organization (docs/mvp-scope.md), so there is no separate path here.
operationId createOrg
Request body: application/json · object
| Status code | Description | Response body |
|---|---|---|
| 201 | The created organization | Org |
| 401 | — | — |
| 409 | — | — |
PATCH /v1/orgs/{orgSlug}
Requires admin or higher. Audited as org.rename.
The slug is not part of this surface. The slug is a name that becomes an address
(<project>.<slug>.runlot.app, the certificate SAN, the clone address, the "org"
field in the runlot.json committed to the customer's repository), so changing it
requires keeping the old name alive for a while — and that alias is exactly what
0027 introduced and 0037 removed (docs/org-settings.md §2).
operationId updateOrg
Request body: application/json · object
| Status code | Description | Response body |
|---|---|---|
| 200 | The updated org | OrgMembership |
| 400 | — | — |
| 403 | — | — |
| 404 | — | — |
DELETE /v1/orgs/{orgSlug}
Requires admin or higher. This cannot be undone.
The confirm field must carry the exact org slug (leading and trailing whitespace is
trimmed).
The API enforces this check because three callers hit this surface — the dashboard, the
CLI, and scripts —
and if each implemented it separately, no one would notice if one left it out.
Deletion succeeds only when the org is empty. If projects, domains, repositories, or
DNS zones
remain, the response is 409 org_not_empty, and the envelope's details.holdings lists
counts
and a few names per kind. The reason is schema-level (RESTRICT constraints in 0044,
0020, and 0032):
deleting the row leaves the node's data in place, and data nothing points to can neither
be deleted
nor billed for.
Two other rejections: a personal org returns personal_org (the org your first login
created,
which is never recreated), and a live paid subscription returns active_subscription
(this prevents the org from disappearing while charges keep hitting the card).
The org.delete audit event outlives the org — audit_events.org_id uses
ON DELETE SET NULL, and the slug remains in target.
operationId deleteOrg
Request body: application/json · object
| Status code | Description | Response body |
|---|---|---|
| 204 | deleted | — |
| 400 | — | — |
| 403 | — | — |
| 404 | — | — |
| 409 | Not empty (org_not_empty) · personal org (personal_org) · active subscription (active_subscription) | Error |
GET /v1/orgs/{orgSlug}/notifications
A list of limit, backup, and restore events (notifications). Newest first.
If sentAt is set, an email went out too. Rows without it are either
still queued or have no email to receive — for the latter, this list is
the only channel: if a GitHub login has a private email, users.email is
NULL (§3.3).
viewer or above.
operationId listNotifications
| Parameter | Location | Required | Type | Description |
|---|---|---|---|---|
limit | query | No | integer | — |
| Status code | Description | Response body |
|---|---|---|
| 200 | List notifications | object |
| 403 | — | — |
| 404 | — | — |
GET /v1/orgs/{orgSlug}/members
Requires member or higher.
operationId listMembers
| Status code | Description | Response body |
|---|---|---|
| 200 | Member list | object |
| 403 | — | — |
| 404 | — | — |
POST /v1/orgs/{orgSlug}/members
This isn't an invite. The user must already have logged in once and exist in
users (identified by provider+subject); if not, this returns 404
user_not_found. If they're already a member, it returns 409 already_member —
changing a role is a PUT.
The surface for inviting people is POST …/invites, not this one
(docs/members.md): GitHub's subject is a numeric id, a value an admin can't
know, so this endpoint is for automation that already knows it.
Requires admin or higher. Audited as member.add.
operationId addMember
Request body: application/json · object
| Status code | Description | Response body |
|---|---|---|
| 201 | Members added | Member |
| 400 | — | — |
| 403 | — | — |
| 404 | The org does not exist, or the user does not exist (user_not_found) | Error |
| 409 | Already a member (already_member) | Error |
GET /v1/orgs/{orgSlug}/invites
Admin or higher. Member or higher can see the member list, but the invite list is different — it's the emails of people not yet in the org, and that's the state of an admin task, not a roster.
Accepted or revoked invites don't appear (the audit log is where history lives). Expired invites do appear — resending is the admin's job there.
operationId listInvites
| Status code | Description | Response body |
|---|---|---|
| 200 | List invites | object |
| 403 | — | — |
| 404 | — | — |
POST /v1/orgs/{orgSlug}/invites
Admin or higher. Email doesn't go out immediately — a cp-core send cycle
sweeps the queue (an empty sentAt means it's still queued).
Calling this again with the same address resends it: instead of a new invite, the same row is updated (a new token and expiry are set, and the role can change), and this returns 200. A new invite returns 201.
An address that's already a member returns 409 already_member. Too many
pending invites returns 409 too_many_invites — this is abuse prevention on
sending, not a plan limit.
Invites last seven days, are single-use, and can be revoked at any time.
Audited as member.invite.
operationId createInvite
Request body: application/json · object
| Status code | Description | Response body |
|---|---|---|
| 200 | Resent an existing invite | Invite |
| 201 | New invite | Invite |
| 400 | — | — |
| 403 | — | — |
| 404 | — | — |
| 409 | Already a member (already_member) or too many pending invites (too_many_invites) | Error |
DELETE /v1/orgs/{orgSlug}/invites/{inviteId}
Admin or higher. Email that hasn't gone out yet won't go out — revoking
also clears the send queue. An invite that's already accepted or revoked
returns 404. Audited as member.uninvite.
operationId revokeInvite
| Status code | Description | Response body |
|---|---|---|
| 204 | Revoked | — |
| 403 | — | — |
| 404 | — | — |
GET /v1/invites/{token}
No authentication. Whoever receives the invite link hasn't logged in yet, and they need to know what it is before they log in. The token is a 32-byte random value, so it can't be enumerated, and the cost of opening it ends at "whoever we sent the email to sees the org name."
State is a value, not an error (state). Returning 404 for an expired invite
would leave the UI unable to distinguish "the link is wrong" from "the link is old."
An unknown token returns 404 — the same answer as a token revoked long ago.
operationId getInvite
| Status code | Description | Response body |
|---|---|---|
| 200 | Invite | InviteView |
| 404 | — | — |
POST /v1/invites/{token}/accept
Requires login. There is no rule that the logged-in account's email must
match the invited address — someone who signs in with a GitHub private
email has no users.email at all, and adding that rule would lock them out
of every org. Instead, invites live for seven days, are single-use, and can
be revoked at any time, and the audit record keeps both the invited
address and the accepting account.
If the person is already a member, their role does not change
(alreadyMember: true). If an invite became a surface for demoting an
admin, that demotion would not pass the last-admin check.
Audited as member.accept.
operationId acceptInvite
| Status code | Description | Response body |
|---|---|---|
| 200 | Accepted | object |
| 401 | — | — |
| 404 | — | — |
| 409 | Already accepted (invite_used), revoked (invite_revoked), or expired (invite_expired) | Error |
PUT /v1/orgs/{orgSlug}/members/{userId}
Requires admin or higher. You cannot demote the last admin (409 last_admin) — this
includes yourself. Without this rule, an org could end up with no one able to manage it,
and the only way out would be editing the DB directly. Audited as member.role.
operationId setMemberRole
Request body: application/json · object
| Status code | Description | Response body |
|---|---|---|
| 200 | The member that changed | Member |
| 400 | — | — |
| 403 | — | — |
| 404 | — | — |
| 409 | This is the last admin (last_admin) | Error |
DELETE /v1/orgs/{orgSlug}/members/{userId}
Removing someone else requires admin or higher; removing yourself is open to anyone
(if userId is your own, it's leaving). If only admins could remove members, someone
invited as a viewer would have no way to leave the org.
The last admin can't be removed or leave (409 last_admin). The audit log records
removal as member.remove and leaving as member.leave — the same statement but
different events, so the names differ.
operationId removeMember
| Status code | Description | Response body |
|---|---|---|
| 204 | removed | — |
| 403 | — | — |
| 404 | — | — |
| 409 | This is the last admin (last_admin) | Error |