# Organizations and workspaces

> Manage Nodaro organizations and workspaces from TypeScript. Create schools and teams, invite members, hand out join codes, and read credit usage reports.

Source: https://nodaro.ai/docs/developers/sdk/organizations

**`client.organizations`** manages organizations, a school or a team, with their members, invitations, audit log and usage reports. **`client.workspaces`** manages the workspaces inside them, such as a class or a team project, with their members and join codes. The methods call the [Workspaces REST API](https://nodaro.ai/docs/developers/api/workspaces). See [Workspaces](https://nodaro.ai/docs/concepts/workspaces) for the concepts.

Organizations are a Nodaro Cloud feature, switched on per instance. On an instance without them, every method here throws `NotFoundError`.

## Belonging and acting

Belonging to a workspace and acting in it are different things. These two resources manage who belongs where. To **act** in a workspace, so that lists read from it and new work lands in it, create a client with [`client.withWorkspace(workspaceId)`](https://nodaro.ai/docs/developers/sdk/client#withworkspaceworkspaceid) or pass `workspaceId` to `createClient`:

```ts
const classroom = client.withWorkspace(workspaceId)
await classroom.workflows.run(workflowId) // runs in the class
await client.workflows.run(workflowId)    // runs in your personal space
```

[`client.me()`](https://nodaro.ai/docs/developers/sdk/client#me) returns the organizations and workspaces you belong to, and `lastWorkspaceId`.

The server decides every permission. Whether a caller may invite, remove or rename is its answer, sent as an error. Do not guess it in your client, because a setting can change it.

## Methods

| Method | What it does |
| --- | --- |
| [`organizations.list()`](#organizationslist) | List the organizations you belong to |
| [`organizations.get(id)`](#organizationsgetid) | Read one organization |
| [`organizations.create(input)`](#organizationscreateinput) | Create a school or a team |
| [`organizations.update(id, input)`](#organizationsupdateid-input) | Rename it or change its settings |
| [`organizations.delete(id)`](#organizationsdeleteid) | Delete an organization |
| [`organizations.transferOwnership(id, userId)`](#organizationstransferownershipid-userid) | Make another member the owner |
| [`organizations.leave(id)`](#organizationsleaveid) | Leave an organization |
| [`organizations.listMembers()`, `updateMember()`, `removeMember()`](#organization-members) | Manage members |
| [`organizations.invite(orgId, input)`](#organizationsinviteorgid-input) | Invite people by email |
| [`organizations.listInvitations()`, `revokeInvitation()`, `resendInvitation()`](#manage-invitations) | Manage sent invitations |
| [`organizations.previewInvitation()`, `acceptInvitation()`](#preview-and-accept-an-invitation) | Show and accept an invitation |
| [`organizations.audit(orgId, opts?)`](#organizationsauditorgid-opts) | Read the audit log |
| [`organizations.usage()`, `usageRows()`, `usageCsv()`](#organization-usage) | Read credit usage |
| [`workspaces.list()`](#workspaceslist) | List the workspaces you belong to |
| [`workspaces.listForOrg(orgId, opts?)`](#workspaceslistfororgorgid-opts) | List an organization's workspaces |
| [`workspaces.get(id)`](#workspacesgetid) | Read one workspace |
| [`workspaces.create(orgId, input)`](#workspacescreateorgid-input) | Create a workspace |
| [`workspaces.update(id, input)`](#workspacesupdateid-input) | Change a workspace |
| [`workspaces.setArchived(id, archived)`](#workspacessetarchivedid-archived) | Archive or unarchive a workspace |
| [`workspaces.listMembers()`, `addMember()`, `updateMember()`, `removeMember()`](#workspace-members) | Manage workspace members |
| [`workspaces.getJoinCode()`, `actOnJoinCode()`](#join-codes) | Manage the join code |
| [`workspaces.join(code)`](#workspacesjoincode) | Join a workspace with a code |
| [`workspaces.usage()`, `usageRows()`, `usageCsv()`](#workspace-usage) | Read a workspace's credit usage |

## client.organizations

### organizations.list()

Lists the organizations you belong to (`GET /v1/orgs`).

```ts
list(): Promise<{ data: OrganizationView[] }>
```

```ts
const { data: orgs } = await client.organizations.list()
```

### organizations.get(id)

Reads one organization (`GET /v1/orgs/:id`).

```ts
get(id: string): Promise<{ data: OrganizationView }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The organization id." },
}}
/>

```ts
const { data: org } = await client.organizations.get(orgId)
console.log(org.status) // pending, active, suspended or deleted
```

### organizations.create(input)

Creates an organization, with you as its owner (`POST /v1/orgs`). A new organization usually starts as `pending` and becomes `active` once Nodaro approves it. While it is pending, only you can see it and nothing in it can change, so tell the user it is waiting for approval.

```ts
create(input: { name: string; kind: "school" | "team"; slug?: string; acceptTerms?: boolean; settings?: OrgSettings }): Promise<{ data: OrganizationView }>
```

<TypeTable
type={{
name: { type: 'string', required: true, description: "The organization's name." },
kind: { type: '"school" | "team"', required: true, description: "The kind of organization. It sets the default settings." },
slug: { type: 'string', description: "A unique short name. A slug in use fails with 409 name_taken." },
acceptTerms: { type: 'boolean', description: "Required for a school: the owner confirms the authority to enroll students." },
settings: { type: 'OrgSettings', description: "Settings that apply to every workspace in the organization." },
}}
/>

```ts
const { data: school } = await client.organizations.create({
name: "Sunrise School",
kind: "school",
acceptTerms: true,
})
```

A school without `acceptTerms: true` fails with `400 terms_required`.

### organizations.update(id, input)

Renames an organization or changes its settings (`PATCH /v1/orgs/:id`).

```ts
update(id: string, input: { name?: string; settings?: OrgSettings }): Promise<{ data: OrganizationView }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The organization id." },
name: { type: 'string', description: "A new name." },
settings: { type: 'OrgSettings', description: "Settings to change. See the settings table below." },
}}
/>

```ts
await client.organizations.update(orgId, {
settings: { default_workflow_visibility: "workspace", allowed_email_domains: ["school.example"] },
})
```

Settings are layered: a workspace setting wins over the organization's, which wins over the default for its kind. Setting a key to `false` is a real value, not "unset".

| Key | Type | Meaning |
| --- | --- | --- |
| `admin_access` | `view` or `edit` | What admins may do with a member's workflow |
| `default_workflow_visibility` | `private` or `workspace` | The visibility of a new workflow |
| `member_access_to_shared` | `view` or `edit` | What members may do with a workflow shared to the workspace |
| `members_can_create_projects` | boolean | Whether members may create projects in the workspace |
| `member_caps_enabled` | boolean | Whether per-member credit limits apply |
| `personal_space_enabled` | boolean | Whether members keep a personal space |
| `workspace_admins_can_invite` | boolean | Whether workspace admins may invite new people |
| `collaborators_can_invite` | boolean | Whether an editor collaborator may invite more collaborators |
| `allowed_email_domains` | string list | Organization only: the email domains that may join |
| `vocabulary_overrides` | object | Organization only: new labels for the kind's words, such as `{ "workspace": "Cohort" }` |

The two organization-only keys are replaced as a whole when you update them.

### organizations.delete(id)

Deletes an organization (`DELETE /v1/orgs/:id`). It is hidden from its former members, but nothing is destroyed. Archive every workspace first: otherwise the call fails with `409 has_active_workspaces`.

```ts
delete(id: string): Promise<{ data: { id: string; status: string } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The organization id." },
}}
/>

```ts
await client.organizations.delete(orgId)
```

### organizations.transferOwnership(id, userId)

Makes another member the owner (`POST /v1/orgs/:id/transfer-ownership`). You become an admin.

```ts
transferOwnership(id: string, userId: string): Promise<{ data: { orgId: string; ownerUserId: string } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The organization id." },
userId: { type: 'string', required: true, description: "The member who becomes the owner." },
}}
/>

```ts
await client.organizations.transferOwnership(orgId, newOwnerId)
```

### organizations.leave(id)

Leaves an organization (`POST /v1/orgs/:id/leave`). The owner cannot leave: transfer ownership first, or the call fails with `409 owner_cannot_leave`.

```ts
leave(id: string): Promise<{ data: { orgId: string; left: boolean } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The organization id." },
}}
/>

```ts
await client.organizations.leave(orgId)
```

### Organization members

List, change and remove the members of an organization (`/v1/orgs/:id/members`). A member is `active` or `suspended`; a suspended member keeps the seat but cannot act. The owner cannot be suspended, removed or demoted.

```ts
listMembers(orgId: string, opts?: { cursor?: string; limit?: number }): Promise<OrgPage<OrgMemberView>>
updateMember(orgId: string, userId: string, input: { role?: "admin" | "member"; status?: "active" | "suspended" }): Promise<{ data: OrgMemberView }>
removeMember(orgId: string, userId: string): Promise<{ data: { removed: boolean } }>
```

<TypeTable
type={{
orgId: { type: 'string', required: true, description: "The organization id." },
userId: { type: 'string', description: "The member. Required by updateMember and removeMember." },
cursor: { type: 'string', description: "listMembers: the nextCursor of the previous page." },
limit: { type: 'number', description: "listMembers: the page size." },
role: { type: '"admin" | "member"', description: "updateMember: the new role." },
status: { type: '"active" | "suspended"', description: "updateMember: suspend or reactivate the member." },
}}
/>

```ts
const { data: members, nextCursor } = await client.organizations.listMembers(orgId)
await client.organizations.updateMember(orgId, userId, { role: "admin" })
```

### organizations.invite(orgId, input)

Invites people by email (`POST /v1/orgs/:id/invitations`). It returns **one row per address**.

```ts
invite(orgId: string, input: {
emails: string[]
orgRole?: "admin" | "member"
workspaceId?: string
workspaceRole?: "admin" | "member"
}): Promise<{ data: InvitationDelivery[] }>
```

<TypeTable
type={{
orgId: { type: 'string', required: true, description: "The organization id." },
emails: { type: 'string[]', required: true, description: "The addresses to invite." },
orgRole: { type: '"admin" | "member"', description: "The role in the organization." },
workspaceId: { type: 'string', description: "A workspace to add the people to when they accept." },
workspaceRole: { type: '"admin" | "member"', description: "Their role in that workspace." },
}}
/>

```ts
const { data: rows } = await client.organizations.invite(orgId, {
emails: ["teacher@school.example"],
workspaceId,
workspaceRole: "admin",
})
for (const row of rows) {
if (row.status !== "sent") showInviteLink(row.email, row.link) // not emailed: share the link yourself
}
```

**Surface the link.** A row whose `status` is not `sent` carries a `link` instead: the install has no mail provider, or the delivery failed. The invitation exists either way, and without the link nobody can reach it. An invitation expires after 14 days. The organization's daily invitation limit answers with `RateLimitedError`.

### Manage invitations

List, revoke and resend invitations.

```ts
listInvitations(orgId: string, opts?: { status?: "open" | "accepted" | "revoked" | "expired"; workspaceId?: string; cursor?: string; limit?: number }): Promise<OrgPage<InvitationView>>
revokeInvitation(id: string): Promise<{ data: { id: string; revoked: boolean } }>
resendInvitation(id: string): Promise<{ data: InvitationDelivery & { id: string } }>
```

<TypeTable
type={{
orgId: { type: 'string', description: "listInvitations: the organization id." },
id: { type: 'string', description: "revokeInvitation and resendInvitation: the invitation id." },
status: { type: '"open" | "accepted" | "revoked" | "expired"', description: "listInvitations: only invitations in this state." },
workspaceId: { type: 'string', description: "listInvitations: only invitations to this workspace." },
cursor: { type: 'string', description: "listInvitations: the nextCursor of the previous page." },
limit: { type: 'number', description: "listInvitations: the page size." },
}}
/>

```ts
const { data: open } = await client.organizations.listInvitations(orgId, { status: "open" })
await client.organizations.resendInvitation(open[0].id)
```

### Preview and accept an invitation

`previewInvitation()` is **public**: it works while the invited person is still signed out, and it returns the address masked. `acceptInvitation()` needs a signed-in caller whose email matches the invitation.

```ts
previewInvitation(token: string): Promise<{ data: InvitationPreview }>
acceptInvitation(token: string): Promise<{ data: { orgId: string; workspaceId: string | null } }>
```

<TypeTable
type={{
token: { type: 'string', required: true, description: "The token from the invitation link." },
}}
/>

```ts
const { data: preview } = await client.organizations.previewInvitation(token)
const { data: joined } = await signedInClient.organizations.acceptInvitation(token)
```

Accepting fails with `400 invitation_expired`, `invitation_revoked`, `invitation_accepted` or `email_mismatch`, and with a `ForbiddenError` when the organization admits only listed email domains.

### organizations.audit(orgId, opts?)

Reads the organization's audit log, newest first (`GET /v1/orgs/:id/audit`). It stays readable while the organization is suspended.

```ts
audit(orgId: string, opts?: { cursor?: string; limit?: number }): Promise<OrgPage<OrgAuditEntry>>
```

<TypeTable
type={{
orgId: { type: 'string', required: true, description: "The organization id." },
cursor: { type: 'string', description: "The nextCursor of the previous page." },
limit: { type: 'number', description: "The page size." },
}}
/>

```ts
const { data: entries } = await client.organizations.audit(orgId, { limit: 50 })
for (const e of entries) console.log(e.createdAt, e.action, e.actor?.displayName ?? "system")
```

Each entry has `id`, `workspaceId`, `action`, `targetType`, `targetId`, `details`, `createdAt` and `actor`, which is `null` for actions the system took. `action` is an **open** list: show the actions you know and the raw string for the rest. Code that handles only a fixed list breaks on the first new action.

### Organization usage

Credit usage reports for a date range, for the owner and the organization admins (`GET /v1/orgs/:id/usage`). `usage()` groups the report, `usageRows()` returns the runs behind it, newest first, a page at a time, and `usageCsv()` returns the report or the rows as CSV text.

```ts
usage(orgId: string, opts?: { from?: string; to?: string; tz?: string; groupBy?: "workspace" | "member" | "model" | "day"; workspaceId?: string; userId?: string }): Promise<{ data: UsageReport }>
usageRows(orgId: string, opts?: { from?: string; to?: string; tz?: string; workspaceId?: string; userId?: string; cursor?: string; limit?: number }): Promise<OrgPage<UsageLogEntry>>
usageCsv(orgId: string, opts?: { from?: string; to?: string; tz?: string; groupBy?: string; workspaceId?: string; userId?: string }): Promise<string>
```

<TypeTable
type={{
orgId: { type: 'string', required: true, description: "The organization id." },
from: { type: 'string', description: "The first day of the range, included." },
to: { type: 'string', description: "The last day of the range, included. A range is at most 366 days." },
tz: { type: 'string', description: "An IANA time zone for the days, such as Europe/Rome." },
groupBy: { type: '"workspace" | "member" | "model" | "day"', description: "usage and usageCsv: how to group the report." },
workspaceId: { type: 'string', description: "Only one workspace." },
userId: { type: 'string', description: "Only one member." },
cursor: { type: 'string', description: "usageRows: the nextCursor of the previous page." },
limit: { type: 'number', description: "usageRows: the page size." },
}}
/>

```ts
const { data: report } = await client.organizations.usage(orgId, {
from: "2026-09-01",
to: "2026-09-30",
tz: "Europe/Rome",
groupBy: "workspace",
})
const csv = await client.organizations.usageCsv(orgId, { from: "2026-09-01", to: "2026-09-30" })
```

Each row reports three credit figures. `credits` is what the runs have cost so far: the settled amount for finished runs and the held reservation for the others. `settledCredits` and `inFlightCredits` split it. The totals cover the whole range, even when a grouping is `truncated`.

- `platformAbsorbedCredits` is the excess of a metered run that went over the workspace's remaining budget, which the platform absorbs.
- `chargedToBudget` equals `settledCredits` minus `platformAbsorbedCredits`: the settled credits that reached the budget.
- `appMarkupAbsorbedCredits` is an app markup the budget could not cover. It has no run in the report, so it is not part of `chargedToBudget`.

CSV exports are limited to ten per minute per user.

## client.workspaces

### workspaces.list()

Lists the workspaces you belong to, and your `lastWorkspaceId` (`GET /v1/workspaces`). It returns summaries, the same list `client.me()` carries.

```ts
list(): Promise<{ data: WorkspaceSummary[]; lastWorkspaceId: string | null }>
```

```ts
const { data: workspaces, lastWorkspaceId } = await client.workspaces.list()
```

### workspaces.listForOrg(orgId, opts?)

Lists the workspaces of one organization (`GET /v1/orgs/:id/workspaces`).

```ts
listForOrg(orgId: string, opts?: { includeArchived?: boolean }): Promise<{ data: WorkspaceView[] }>
```

<TypeTable
type={{
orgId: { type: 'string', required: true, description: "The organization id." },
includeArchived: { type: 'boolean', default: 'false', description: "Include archived workspaces." },
}}
/>

```ts
const { data: classes } = await client.workspaces.listForOrg(orgId)
```

### workspaces.get(id)

Reads one workspace in full (`GET /v1/workspaces/:id`).

```ts
get(id: string): Promise<{ data: WorkspaceView }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The workspace id." },
}}
/>

```ts
const { data: workspace } = await client.workspaces.get(workspaceId)
```

### workspaces.create(orgId, input)

Creates a workspace in an organization (`POST /v1/orgs/:id/workspaces`). The organization must be active.

```ts
create(orgId: string, input: { name: string; slug?: string; description?: string; settings?: WorkspaceSettings }): Promise<{ data: WorkspaceView }>
```

<TypeTable
type={{
orgId: { type: 'string', required: true, description: "The organization id." },
name: { type: 'string', required: true, description: "The workspace name." },
slug: { type: 'string', description: "A unique short name." },
description: { type: 'string', description: "A description." },
settings: { type: 'WorkspaceSettings', description: "Settings that override the organization's." },
}}
/>

```ts
const { data: classOne } = await client.workspaces.create(orgId, { name: "Class 1" })
```

### workspaces.update(id, input)

Changes a workspace (`PATCH /v1/workspaces/:id`).

```ts
update(id: string, input: { name?: string; description?: string | null; settings?: WorkspaceSettings }): Promise<{ data: WorkspaceView }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The workspace id." },
name: { type: 'string', description: "A new name." },
description: { type: 'string | null', description: "A new description, or null to remove it." },
settings: { type: 'WorkspaceSettings', description: "Settings to change." },
}}
/>

```ts
await client.workspaces.update(workspaceId, { settings: { members_can_create_projects: true } })
```

### workspaces.setArchived(id, archived)

Archives or unarchives a workspace (`POST /v1/workspaces/:id/archive` or `/unarchive`). Archiving can be undone and destroys nothing: the workspace stops accepting new work and stays fully readable. A write into an archived workspace fails with a `ForbiddenError`.

```ts
setArchived(id: string, archived: boolean): Promise<{ data: WorkspaceView }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The workspace id." },
archived: { type: 'boolean', required: true, description: "true archives, false unarchives." },
}}
/>

```ts
await client.workspaces.setArchived(workspaceId, true)
```

### Workspace members

List, add, change and remove the members of a workspace (`/v1/workspaces/:id/members`). A person must already belong to the organization to be added; to bring in someone new, invite them. Organization owners and admins are admins of every workspace without being listed.

```ts
listMembers(id: string, opts?: { cursor?: string; limit?: number }): Promise<OrgPage<WorkspaceMemberView>>
addMember(id: string, input: { userId: string; role: "admin" | "member" }): Promise<{ data: WorkspaceMemberView }>
updateMember(id: string, userId: string, input: { role?: "admin" | "member"; status?: "active" | "suspended"; creditCap?: number | null }): Promise<{ data: WorkspaceMemberView }>
removeMember(id: string, userId: string): Promise<{ data: { removed: boolean } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The workspace id." },
userId: { type: 'string', description: "The member. Required by addMember, updateMember and removeMember." },
role: { type: '"admin" | "member"', description: "The workspace role." },
status: { type: '"active" | "suspended"', description: "updateMember: suspend or reactivate the member in this workspace." },
creditCap: { type: 'number | null', description: "updateMember: a credit limit for this member, or null for none." },
cursor: { type: 'string', description: "listMembers: the nextCursor of the previous page." },
limit: { type: 'number', description: "listMembers: the page size." },
}}
/>

```ts
await client.workspaces.addMember(workspaceId, { userId, role: "member" })
await client.workspaces.updateMember(workspaceId, userId, { creditCap: 500 })
```

Adding someone who is not an active member of the organization fails with `400 not_org_member`, and adding someone twice fails with `409 already_a_member`.

### Join codes

A join code lets people join a workspace without an invitation. Only workspace admins can read or change it.

```ts
getJoinCode(id: string): Promise<{ data: JoinCodeView | null }>
actOnJoinCode(id: string, action: "rotate" | "enable" | "disable"): Promise<{ data: JoinCodeView }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The workspace id." },
action: { type: '"rotate" | "enable" | "disable"', description: "actOnJoinCode: rotate makes a new code and stops the old one at once. enable and disable switch the code on and off." },
}}
/>

```ts
const { data: code } = await client.workspaces.actOnJoinCode(workspaceId, "enable")
console.log(code.code) // for example BCDFGHJK
```

`getJoinCode()` returns `null` when no code has been made yet.

### workspaces.join(code)

Joins a workspace with its join code (`POST /v1/workspaces/join`). It works whatever workspace your client acts in, so a stale selection never blocks it.

```ts
join(code: string): Promise<{ data: { orgId: string; workspaceId: string } }>
```

<TypeTable
type={{
code: { type: 'string', required: true, description: "The join code." },
}}
/>

```ts
const { data: joined } = await client.workspaces.join("BCDFGHJK")
```

An unknown or disabled code, or a code of an archived workspace, fails with `400 join_code_invalid`: one answer for all three, so a code cannot reveal what exists. Too many attempts answer with `RateLimitedError`.

### Workspace usage

The same credit reports as the organization's, for one workspace (`GET /v1/workspaces/:id/usage`). A member sees their own runs; an admin sees everyone's and may filter by `userId`.

```ts
usage(id: string, opts?: { from?: string; to?: string; tz?: string; groupBy?: "member" | "model" | "day"; userId?: string }): Promise<{ data: UsageReport }>
usageRows(id: string, opts?: { from?: string; to?: string; tz?: string; userId?: string; cursor?: string; limit?: number }): Promise<OrgPage<UsageLogEntry>>
usageCsv(id: string, opts?: { from?: string; to?: string; tz?: string; groupBy?: string; userId?: string }): Promise<string>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The workspace id." },
from: { type: 'string', description: "The first day, included." },
to: { type: 'string', description: "The last day, included." },
tz: { type: 'string', description: "An IANA time zone." },
groupBy: { type: '"member" | "model" | "day"', description: "How to group the report." },
userId: { type: 'string', description: "Admins only: one member." },
cursor: { type: 'string', description: "usageRows: the nextCursor of the previous page." },
limit: { type: 'number', description: "usageRows: the page size." },
}}
/>

```ts
const { data: byModel } = await client.workspaces.usage(workspaceId, { groupBy: "model" })
```

## Errors

The server decides every permission and answers with an error. In the SDK, a 403 becomes `ForbiddenError`, a 404 `NotFoundError` and a 429 `RateLimitedError`; read their `message` for the reason. Other statuses arrive as `NodaroError` with the server's `code`.

| Status | Code | When |
| --- | --- | --- |
| 400 | `validation_error` | A field is invalid, a cursor is malformed, or a usage report has a bad date, time zone or range |
| 400 | `terms_required` | A school was created without `acceptTerms: true` |
| 400 | `not_org_member` | The person added to a workspace is not an active member of its organization |
| 400 | `token_workspace_mismatch` | A token bound to one workspace was used with another workspace |
| 400 | `join_code_invalid` | The join code does not exist, is disabled, or belongs to an archived workspace |
| 400 | `invitation_expired`, `invitation_revoked`, `invitation_accepted`, `email_mismatch` | The invitation cannot be accepted |
| 403 | | The role is too low, the member is suspended, the organization is not active, the workspace is archived, or the email domain is not allowed |
| 404 | | No such organization, workspace, member or invitation, or one you are not a member of |
| 409 | `name_taken` | The slug is in use |
| 409 | `already_a_member` | The person is already in the workspace |
| 409 | `owner_cannot_leave` | The owner tried to leave |
| 409 | `has_active_workspaces` | The organization still has workspaces that are not archived |
| 429 | | Too many organizations created, join attempts, CSV exports or invitations |
| 503 | `billing_unavailable` | Usage reports are not available on this instance yet |
| 503 | `audit_unavailable` | A CSV export could not be recorded in the audit log. Try again. |

## Frequently asked questions

### What is the difference between an organization and a workspace?

An organization is the school or the team, with one owner, admins and members. A workspace is where members work together inside it, such as a class or a project team. Every workspace belongs to one organization.

### How do I run a workflow inside a workspace?

Create a client for that workspace with client.withWorkspace(workspaceId) and call run on it. The original client keeps working in your personal space.

### Why is my new organization pending?

A new organization starts as pending and becomes active once Nodaro approves it. Until then only its owner can see it, and nothing in it can change.

### What should I do when an invitation comes back with a link?

Show the link to whoever sent the invitation. A row whose status is not sent was not emailed, because the install has no mail provider or the delivery failed. The invitation exists, and the link is the only way to reach it.
