Organizations Federation API
Complete HTTP and SDK API reference for Organizations Service
Organizations Federation API
This reference is for clients and product backends that call Federation. For the backend that consumes Organization Tokens and revocation events, see Integrate Organizations into a City Server.
Install and configure
import { Federation } from "@downcity/city";
import { OrganizationsService } from "@downcity/services";
const federation = new Federation({ db });
federation.use(new OrganizationsService({
max_organizations_per_user: 3,
organization_token_ttl: "7d",
}));| Option | Required | Default | Meaning |
|---|---|---|---|
max_organizations_per_user | yes | none | Maximum active Organizations a user may own in one City |
organization_token_ttl | no | 7d | Token TTL as seconds or an s/m/h/d string; maximum 30d |
fetch | no | globalThis.fetch | Revocation-event transport override for custom runtimes and tests |
The service supports better-sqlite3 and PostgreSQL. It rejects D1 because D1 does not provide the required interactive multi-table transactions. Production deployments should configure a Federation Queue adapter for Outbox retries.
Base URL and authentication
Every Action is exposed under:
{federation_origin}/v1/organizations/{action}Except for events/deliver, all Actions require a Federation user token:
Authorization: Bearer ub_<JWT>Federation derives user_id and city_id from the verified token. Body or query values cannot
override either identity. events/deliver accepts only Federation Admin credentials or an internal
trusted runtime identity.
GET Actions read query parameters; POST Actions read JSON. Successful responses are JSON with
status 200. Timestamps are ISO 8601 strings, and unset timestamps currently use "".
GET /v1/organizations/get?organization_id=org_01J...
Authorization: Bearer ub_...const organizations = city.service("organizations");
const my = await organizations.get("my");
const created = await organizations.action("create").invoke({
name: "Research Team",
server_url: "https://city.example.com",
});Data types
type Organization = {
organization_id: string;
city_id: string;
name: string;
server_url: string;
state: "active" | "archived";
created_by: string;
created_at: string;
updated_at: string;
archived_at: string;
};
type Membership = {
membership_id: string;
organization_id: string;
user_id: string;
role: "owner" | "admin" | "member";
state: "active" | "removed";
created_at: string;
updated_at: string;
removed_at: string;
removed_by: string;
};
type JoinRequest = {
request_id: string;
organization_id: string;
user_id: string;
state: "pending" | "approved" | "rejected" | "canceled";
requested_at: string;
decided_at: string;
decided_by: string;
};server_url is a normalized HTTP or HTTPS origin with no credentials, path, query, or fragment.
A removed Membership is never restored; rejoining creates a new membership_id.
Role permissions
| Operation | Owner | Admin | Member |
|---|---|---|---|
| Read Organization and active members | yes | yes | yes |
| Rename Organization | yes | yes | no |
Change server_url or archive | yes | no | no |
| Decide Join Requests | yes | yes | no |
| Assign or revoke Admin | yes | no | no |
| Remove Member | yes | yes | no |
| Remove Admin | yes | no | no |
| Transfer ownership | yes | no | no |
| Leave | transfer first | yes | yes |
| Issue own Organization Token | yes | yes | yes |
Owners and Admins cannot add users directly. Every user must create their own Join Request.
Action summary
| Method | Action | Authorization | Purpose |
|---|---|---|---|
GET | my | User | List the caller's Organizations |
GET | get | Member | Read one joined Organization |
POST | create | User | Create an Organization as Owner |
POST | update | Owner/Admin | Rename an Organization |
POST | server/update | Owner | Change the City Server origin |
POST | archive | Owner | Terminally archive an Organization |
GET | membership/get | Member | Read the caller's Membership |
GET | members/list | Member | List active Memberships |
POST | members/role | Owner | Set an Admin or Member role |
POST | members/remove | Owner/Admin | Remove a manageable member |
POST | members/leave | Admin/Member | Leave the Organization |
POST | owner/transfer | Owner | Transfer the unique Owner role |
POST | join-requests/create | User | Apply to join |
POST | join-requests/cancel | Applicant | Cancel a pending request |
GET | join-requests/list | Owner/Admin | List pending requests |
POST | join-requests/decide | Owner/Admin | Approve or reject a request |
POST | token/create | Member | Issue an Organization Token |
POST | events/deliver | Federation Admin | Manually advance the Outbox |
Organization Actions
GET my
GET /v1/organizations/my
Authorization: Bearer ub_...Returns { "items": UserOrganization[] }. Each item combines Organization fields with role,
membership_id, and membership_state. Only active Memberships are listed. An archived
Organization remains visible while the caller's Membership remains active.
GET get
GET /v1/organizations/get?organization_id=org_01J...Returns { organization, membership } for the caller's active Membership. It can read an archived
Organization but cannot restore or mutate it.
POST create
{
"name": "Research Team",
"server_url": "https://city.example.com/"
}Any authenticated user can create within their Owner quota. Federation derives the City from the
token, normalizes the URL, and returns { organization, membership }; the Membership is the unique
Owner. Archiving releases a slot, and an ownership transfer moves the slot to the new Owner.
POST update
{ "organization_id": "org_01J...", "name": "New Name" }Owner/Admin only. The trimmed name must contain 1–120 characters. Returns the updated Organization.
POST server/update
{
"organization_id": "org_01J...",
"server_url": "https://new-city.example.com"
}Owner only. The update and organization.server_url.changed Outbox event commit atomically. The
event targets the old Server, while newly issued tokens target only the new Server. Repeating the
same URL is idempotent and creates no event.
POST archive
{ "organization_id": "org_01J..." }Owner only and irreversible. Archiving stops governance writes, Join Requests, and token issuance;
releases the Owner quota slot; and writes an organization.archived event. Records remain for
history. Repeating the operation returns 410 ORGANIZATION_ARCHIVED.
Membership Actions
GET membership/get
GET /v1/organizations/membership/get?organization_id=org_01J...Returns { organization, membership }, where Membership belongs to the caller and is active.
GET members/list
GET /v1/organizations/members/list?organization_id=org_01J...Any active Member may call it. Returns { "items": Membership[] } containing only active records.
POST members/role
{
"organization_id": "org_01J...",
"membership_id": "mem_01J...",
"role": "admin"
}Owner only. role accepts admin or member. Use owner/transfer, not this Action, for ownership.
POST members/remove
{
"organization_id": "org_01J...",
"membership_id": "mem_01J..."
}An Owner can remove Admins or Members; an Admin can remove Members only. Owner removal is rejected.
The returned Membership has state: "removed", and its update commits atomically with a signed
revocation event.
POST members/leave
{ "organization_id": "org_01J..." }Admins and Members can leave. The response is the removed Membership. An Owner must transfer first.
POST owner/transfer
{
"organization_id": "org_01J...",
"membership_id": "mem_new_owner"
}The target must be active and have an available Owner quota slot. Returns
{ previous_owner, owner }; the previous Owner becomes Admin.
Join Request Actions
POST join-requests/create
{ "organization_id": "org_01J..." }The service has no public Organization search. Products distribute organization_id through their
own invitation flow. The first call returns a pending Join Request; a duplicate returns the same
record. An existing active Member receives { state: "joined", organization, membership }.
POST join-requests/cancel
{ "request_id": "join_01J..." }The applicant can cancel their own pending request. Returns the request with state: "canceled".
GET join-requests/list
GET /v1/organizations/join-requests/list?organization_id=org_01J...Owner/Admin only. Returns { "items": JoinRequest[] } with pending requests only.
POST join-requests/decide
{ "request_id": "join_01J...", "decision": "approved" }Owner/Admin only. decision is approved or rejected. Approval creates a new Member Membership
in the same transaction and returns { request, membership }. Rejection returns the updated
request without membership. Rejected or canceled users may apply again with a new request ID.
Token Action
POST token/create
{ "organization_id": "org_01J..." }Returns:
{
"organization_token": "ot_eyJ...",
"organization_id": "org_01J...",
"server_url": "https://city.example.com",
"expires_at": "2026-08-05T10:00:00.000Z"
}The caller must have an active Membership in an active Organization. The token defaults to 7 days,
binds aud to server_url, and contains user, City, Organization, and Membership IDs. It contains
no governance role or project permission and cannot authenticate to Federation APIs.
Outbox administration
POST events/deliver
POST /v1/organizations/events/deliver
Authorization: Bearer <federation-admin-secret>
Content-Type: application/json
{}Returns { "delivered": number, "pending": number }. pending is the total remaining Outbox count
after the delivery attempt. Prefer automatic Queue retries; use this Action for operational replay.
Errors
{
"error": {
"code": "ORGANIZATION_ROLE_DENIED",
"message": "ORGANIZATION_ROLE_DENIED",
"type": "server_error"
}
}| HTTP | Code | Meaning |
|---|---|---|
400 | ORGANIZATION_INPUT_INVALID | Invalid name, ID, role, or decision |
400 | ORGANIZATION_SERVER_URL_INVALID | Invalid Server origin |
401 | AUTH_REQUIRED or absent | User credential is missing, expired, or invalid |
403 | ORGANIZATION_CITY_MISMATCH | Token City does not own the Organization |
403 | NOT_AN_ORGANIZATION_MEMBER | Caller has no active Membership |
403 | ORGANIZATION_ROLE_DENIED | Governance role cannot perform the operation |
404 | ORGANIZATION_NOT_FOUND | Organization does not exist |
404 | ORGANIZATION_MEMBERSHIP_NOT_FOUND | Target Membership is absent or inactive |
404 | JOIN_REQUEST_NOT_FOUND | Request is absent or no longer pending |
409 | ORGANIZATION_LIMIT_REACHED | Owner quota is full |
409 | OWNER_TRANSFER_REQUIRED | Transfer ownership before the operation |
410 | ORGANIZATION_ARCHIVED | Organization is archived |
Branch on error.code, never on the human-readable message.