Workspaces and organizations
Act in an organization's workspace from the Nodaro API with X-Nodaro-Workspace, bind tokens to a workspace, and manage members, invitations, sharing and usage.
Available on Nodaro Cloud
Workspaces are where an organization's members work together: a class in a school, a team in a company. An API request acts in one workspace when you send its id in the X-Nodaro-Workspace header, and the organization endpoints manage everything around it: organizations, members, invitations, join codes, sharing, budgets and usage.
Organizations are a Nodaro Cloud feature and are switched on per instance. Where they are not enabled, the endpoints below do not exist and the header is ignored. Self-hosted builds do not include organizations. Read Workspaces for the concepts.
Act in a workspace
X-Nodaro-Workspace: 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34The header does two things, and only these two: it decides which workspace a list returns and where a create lands. It never grants access. Reading, updating, deleting or running something you name by id follows that object's own workspace. A forgotten header cannot hide your work, and a forged one cannot reach anyone else's.
Without the header you work in your personal space, as an account without an organization always does.
curl -s https://app.nodaro.ai/v1/workflows \
-H "Authorization: Bearer $NODARO_API_KEY" \
-H "X-Nodaro-Workspace: 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34"// Every request of this client acts in the workspace:
const classClient = createClient({
baseUrl: 'https://app.nodaro.ai',
auth: new StaticTokenAuth(process.env.NODARO_API_KEY!),
workspaceId: '6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34',
})
// Or derive a second client from an existing one:
const classroom = client.withWorkspace(workspaceId)
await classroom.workflows.run(workflowId) // lands in the class
await client.workflows.run(workflowId) // lands in the personal spacenodaro --workspace 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34 workflows list # this command only
export NODARO_WORKSPACE=6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34 # this shell or CI job
nodaro workspace use 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34 # saved on the profile
nodaro workspace current # which one applies, and whyclient.withWorkspace returns a new client instead of changing the old one, so two operations running at the same time can never race over which workspace they are in. Pass null for the personal space. In the CLI, each of the three ways beats the one below it, and nodaro workspace use checks the workspace before saving it.
Send the header only for a workspace you belong to:
| Status | Code | When |
|---|---|---|
| 400 | validation_error | The value is not a UUID. |
| 403 | not_a_member | You are not a member of that workspace, it does not exist, or its organization is not active. |
| 403 | member_suspended | Your membership is suspended. |
Two places never refuse a stale selection, so it can never lock you out: GET /v1/me and GET /v1/workspaces, and accepting an invitation. There, a workspace you can no longer select is treated as if you sent no header. Those are the endpoints that tell you which workspaces you may select, so clear a cached selection when they stop listing it.
Bind a token to a workspace
An API token can be bound to one workspace. It then acts as if it sent the header on every request, and an explicit header naming a different workspace answers 400 token_workspace_mismatch.
# Bind
curl -X PATCH https://app.nodaro.ai/v1/api-tokens/$TOKEN_ID \
-H "Authorization: Bearer $SESSION_JWT" \
-H "Content-Type: application/json" \
-d '{"workspaceId": "6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34"}'
# Unbind
curl -X PATCH https://app.nodaro.ai/v1/api-tokens/$TOKEN_ID \
-H "Authorization: Bearer $SESSION_JWT" \
-H "Content-Type: application/json" \
-d '{"workspaceId": null}'Token management needs your signed-in session's JWT: an API token or an OAuth token gets 403 forbidden. You can bind a token only to a workspace you could select with the header, and unbinding is always allowed. Listing your tokens returns the binding as workspaceId.
Where a create lands
- Inside a workspace, a create lands in the workspace, never in your personal space. A create that names no project goes to the workspace's project. When the workspace has no project yet, the create answers
409 workspace_has_no_default_project: name a project and it succeeds. - A project you name must belong to the workspace you act in. A project of another workspace, or of your personal space, answers
404 Project not found. That is the same answer as a project that does not exist, so the header cannot be used to discover what exists. - Creating a project may be limited to admins. Members then get
403 project_create_not_allowed. - An organization can close the personal space. Its members then create only inside a workspace, and a create without the header answers
403 personal_space_disabled. Accounts without an organization are never affected.
Archived workspaces are read-only
Archiving a workspace keeps everything in it readable and stops new work from being added. Lists behave as before. Every create, whether a project, a workflow, an import or a sub-workflow, answers 409 workspace_archived, and so does moving work into the workspace. Moving work out stays allowed, because rescuing it is the reason to open an archived workspace. Other writes into it, such as sharing a workflow, answer 403 workspace_archived. Match on the code, not on the status.
Who you are: GET /v1/me
GET /v1/me returns your profile. On an instance with organizations it also carries organizations, workspaces and lastWorkspaceId, each entry with your own role and status. Organization entries include the resolved vocabulary, such as the word the organization uses for a workspace, so a client never hard-codes "Class" or "Team". GET /v1/workspaces returns the same workspace list on its own.
Keep the three states of the organization fields apart:
| What you see | What it means | What to do |
|---|---|---|
| The fields are absent | This instance has no organizations. | Never show a workspace switcher. |
| Present and empty | The account belongs to no organization. | Offer to create or join one. |
organizationsUnavailable: true | The lookup failed. | Keep the selection you had. |
Organization endpoints
Every endpoint needs authentication. Bodies are JSON, responses are { "data": … }, lists are { "data": [ … ], "nextCursor": … }, and ids are UUIDs.
Organizations
| Method | Path | Who | Body or query |
|---|---|---|---|
POST | /v1/orgs | Anyone signed in | { name, kind, slug?, acceptTerms?, settings? }, where kind is school or team. Returns 201. |
GET | /v1/orgs | Anyone | Your organizations, with your role and memberStatus. |
GET | /v1/orgs/:id | Member | |
PATCH | /v1/orgs/:id | Owner, admin | { name?, settings? }. Settings merge key by key, and unknown keys are dropped. |
POST | /v1/orgs/:id/transfer-ownership | Owner | { userId }. The new owner must be an active admin, and the old owner becomes an admin in the same step. |
DELETE | /v1/orgs/:id | Owner | Refused with 409 has_active_workspaces while any workspace is not archived. The organization is soft-deleted and its slug freed. |
POST | /v1/orgs/:id/leave | Member | The owner cannot leave (409 owner_cannot_leave): transfer first. Leaving removes you from every workspace of the organization. |
- Approval. A new organization usually starts
pendingand becomesactiveonce the platform approves it. Its owner sees it while it waits, nobody else does, and nothing in it can change. Tell whoever created it, or the wait looks like a failure. - Schools need terms. Creating a school requires
acceptTerms: true, by which the owner confirms the authority to enroll students. Without it, the create answers400 terms_required. - Slugs are 1 to 50 lower-case letters, digits and hyphens. A slug you send must be free (
409 name_taken). Without one, it is derived from the name: "Sunrise School" becomessunrise-school, thensunrise-school-2. - Creating organizations is limited to a few per hour for each user.
Members
| Method | Path | Who | Body or query |
|---|---|---|---|
GET | /v1/orgs/:id/members | Owner, admin | ?limit=50&cursor=…, up to 200. Rows: userId, role, status, joinedAt, email, displayName, avatarUrl. |
PATCH | /v1/orgs/:id/members/:userId | Owner, admin | { role?, status? }: admin or member, active or suspended. Not the owner's row. |
DELETE | /v1/orgs/:id/members/:userId | Owner, admin | Not the owner. Also removes the person from every workspace of the organization. |
Workspaces
| Method | Path | Who | Body or query |
|---|---|---|---|
POST | /v1/orgs/:id/workspaces | Owner, admin | { name, slug?, description?, settings? }. Returns 201. Slugs are unique within the organization. |
GET | /v1/orgs/:id/workspaces | Member | Members see their workspaces, owners and admins see all. ?includeArchived=true adds archived ones. |
GET | /v1/workspaces | Anyone | Every workspace you belong to, across organizations. |
GET | /v1/workspaces/:id | Workspace member | |
PATCH | /v1/workspaces/:id | Workspace admin | { name?, description?, settings? } |
POST | /v1/workspaces/:id/archive | Owner, admin | Archive. Reversible, and nothing is destroyed. |
POST | /v1/workspaces/:id/unarchive | Owner, admin | Unarchive. |
Workspace members
| Method | Path | Who | Body or query |
|---|---|---|---|
GET | /v1/workspaces/:id/members | Workspace member | ?limit&cursor. Members see userId, role, displayName, avatarUrl and addedAt. Admins also see status and creditCap. The roster has no email addresses. |
POST | /v1/workspaces/:id/members | Workspace admin | { userId, role }, with role admin or member. The person must already be an active member of the organization (400 not_org_member): to bring in someone new, invite them. |
PATCH | /v1/workspaces/:id/members/:userId | Workspace admin | { role?, status?, creditCap? }. creditCap is the member's spending cap, or null for none. |
DELETE | /v1/workspaces/:id/members/:userId | Workspace admin | Removes the person from the workspace only. They stay in the organization. |
Organization owners and admins are admins of every workspace in the organization without being listed. Adding one of them to a workspace explicitly gives them that explicit role instead, which is how an organization admin can be a plain member of one class.
Invitations
An invitation names one email address. Its link carries a token that exists only in the email, and the invitee sees what they are invited to before signing in.
| Method | Path | Who | Body or query |
|---|---|---|---|
POST | /v1/orgs/:id/invitations | Owner, admin, and workspace admins into their own workspace when the organization allows it | { emails, orgRole?, workspaceId?, workspaceRole? }. Up to 200 addresses, lower-cased and de-duplicated. Returns 201 with one row per address: { email, status, link? }. |
GET | /v1/orgs/:id/invitations | The same people | ?status= (open, accepted, revoked or expired), workspaceId, limit and cursor. Never returns a token. |
DELETE | /v1/invitations/:id | The same people | Revoke. The link stops working. Refused on an accepted invitation. |
POST | /v1/invitations/:id/resend | The same people | Issues a new token and expiry and sends it again. The previous link stops working. |
GET | /v1/invitations/by-token/:token | Public | What the invitee needs to decide: orgName, kind, vocabulary, inviterName, workspaceName, the email address masked, expiresAt and state. Limited per IP address. |
POST | /v1/invitations/:token/accept | The signed-in invitee | The account's email must match the invitation (400 email_mismatch). A second acceptance is refused. |
- Each address gets its own row.
statusissent,link_onlyorfailed, andlinkis present whenever the address was not emailed. An install without a mail provider returns every address aslink_only. Show those links: the invitation exists either way, and without the link nobody can reach it. - Invitations expire after 14 days, and an organization may send 500 a day (
429 bulk_invite_cap_exceeded). - Inviting an address again moves its open invitation to a fresh token instead of creating a second one.
- Inviting an existing member uses up the invitation but does not change their role or lift a suspension.
Join codes
A join code is eight characters, short enough to read aloud in a room. It admits a plain member into one workspace, never lifts a suspension, and respects the organization's allowed email domains.
| Method | Path | Who | Body or query |
|---|---|---|---|
GET | /v1/workspaces/:id/join-code | Workspace admin | { code, enabled, rotatedAt, rotatedBy }, or null when none was created. |
POST | /v1/workspaces/:id/join-code | Workspace admin | { action }: rotate, enable or disable. Enabling a workspace that never had a code creates one. Rotating replaces it, and the old code stops working at once. |
POST | /v1/workspaces/join | Anyone signed in | { code }. Spoken forms work: BCDF-GHJK, lower case, and commonly misheard letters such as O for zero. Limited to 10 attempts per minute for each account and 30 for each IP address. |
Sharing one workflow
A grant gives one person access to one workflow. It can only add access, never remove it.
| Method | Path | Who | Body or query |
|---|---|---|---|
GET | /v1/workflows/:id/collaborators | Anyone who may view the workflow | Rows of { userId, name, avatarUrl, role, createdAt }, never an email address. |
POST | /v1/workflows/:id/collaborators | See below | { userId } or { email }, exactly one, plus role: viewer or editor. Returns 201. Limited to 20 additions per minute. |
PATCH | /v1/workflows/:id/collaborators/:userId | Same as POST | { role } |
DELETE | /v1/workflows/:id/collaborators/:userId | Same as POST, or yourself | Anyone may remove their own access. |
GET | /v1/workflows/shared-with-me | Anyone signed in | Workflows you hold a grant on, outside your own workspaces, each with its grantedRole. Newest first, up to 200. |
GET | /v1/workflows/:id/access | Anyone who may view it | { access, workspaceId, visibility, canChangeVisibility, canShare, canRun }, never the graph. |
- Who may share: the creator; a workspace admin where admins may edit members' work; and anyone who may edit the workflow where the workspace lets collaborators invite.
- Adding by email matches any account's address, whatever its organization.
404means there is no account, and nothing more is disclosed. - Someone outside the workspace is capped at viewing, even with an editor grant. Running needs active membership, so an outside collaborator can never spend the workspace's credits.
- You cannot grant to the workflow's creator or to yourself (
400). A grant works at once, with no acceptance step. canChangeVisibility,canShareandcanRunare three separate rules. Never infer one from another.
A workflow you cannot reach at all answers 404, the same as one that does not exist. Only when you can already see it and need more does the answer become 403. Running a workflow that belongs to a workspace needs edit access and active membership.
Audit log
| Method | Path | Who | Body or query |
|---|---|---|---|
GET | /v1/orgs/:id/audit | Owner, admin | ?cursor&limit, newest first. Each entry: { id, workspaceId, action, targetType, targetId, details, createdAt, actor }. actor is null for actions the system took. |
The audit log stays readable while the organization is suspended. action is an open list that grows with the product: show the actions you know, and fall back to the raw value for the rest.
Roles and settings
| Scope | Role | Can |
|---|---|---|
| Organization | owner | Everything an admin can, plus transfer ownership and delete the organization. There is exactly one. The owner cannot be suspended, removed or demoted. |
| Organization | admin | Manage settings, members other than the owner, and workspaces. Admin of every workspace. |
| Organization | member | Belong to the organization and work in the workspaces they are added to. |
| Workspace | admin | Manage the workspace's settings and members. |
| Workspace | member | Work in the workspace. |
Settings are layered: a workspace's setting wins over the organization's, which wins over the default for the organization's kind. A key that is not set falls through, and false is a real value.
| Key | Values | Controls |
|---|---|---|
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 with the workspace. |
members_can_create_projects | boolean | Whether members may create projects. |
member_caps_enabled | boolean | Whether per-member credit caps apply. |
personal_space_enabled | boolean | Whether members keep a personal space. |
workspace_admins_can_invite | boolean | Whether workspace admins may invite new people into the organization. |
collaborators_can_invite | boolean | Whether an editor collaborator may invite more collaborators. |
The organization alone also has allowed_email_domains, such as ["school.example"], and vocabulary_overrides, such as { "workspace": "Cohort" }. Both are replaced whole when updated.
A school starts with: admins edit, workflows private, members view shared work, members cannot create projects, caps on, personal space on, workspace admins can invite, collaborators cannot. A team starts with: admins view, workflows visible to the workspace, members edit shared work, members can create projects, caps off, personal space on, and both invite settings on.
Budgets and usage
An organization pays for its members' work in three steps, each narrower than the last:
- The pool. The organization buys prepaid credit packs into one pool for the whole organization, through its own checkout.
- Allocations. The owner moves credits from the pool into a workspace's budget. The pool goes down by exactly what the workspace gains. Credits can go back to the pool, but only down to what the workspace has already reserved or spent.
- Member caps. Inside a workspace, an admin can cap what one member may spend. Caps apply only where the workspace's settings turn them on, and never to an organization admin working in the workspace.
Work done inside a workspace is paid by the workspace, and the member's own credits are not touched. Headroom at every level is allocated minus reserved minus spent, and a run that would exceed it is refused with 402 budget_exceeded or 402 member_cap_exceeded before it starts.
| Route | Who | Does |
|---|---|---|
GET /v1/orgs/:id/credits | Owner, org admin | The pool, the lifetime purchases, and each workspace's allocation with its reserved and spent credits. |
POST /v1/orgs/:id/credits/checkout | Owner | A checkout URL for one prepaid pack. Body { packId }. |
POST /v1/orgs/:id/workspaces/:wsId/allocate | Owner | Move credits between the pool and a workspace. Body { delta }. Returns the new headroom. |
GET /v1/workspaces/:id/budget | Member | Your spend, cap and headroom. Admins also get a row for each member. |
GET /v1/orgs/:id/usage | Owner, org admin | A usage report for the organization. |
GET /v1/workspaces/:id/usage | Member, workspace admin | The same for one workspace. |
Refunds and disputes take credits back from the organization's pool in proportion, never below zero. Deleting an organization requires taking back every workspace allocation first.
Automations spend the workspace's credits. A Webhook Trigger URL is a bearer credential: whoever holds it can start a run, and a run of a workflow in a workspace is paid by that workspace. Every automated run, from a webhook, a schedule or Telegram, checks first that the trigger's creator may still run the workflow. When they no longer can, the automation stops and the run history shows one failed entry with the code run_requires_authenticated_member.
Usage reports
The two usage routes answer who spent how much on what, over a date range:
| Query | Meaning |
|---|---|
from, to | Inclusive dates, YYYY-MM-DD. The range is at most 366 days and defaults to the last 30. |
tz | An IANA time zone for the day buckets. The default is UTC. |
groupBy | workspace (organization only), member, model, day, or none for the individual runs, newest first, paged with cursor. |
workspaceId, userId | Narrow the report. |
format | csv returns the report as CSV. |
- Each row has three credit figures.
creditsis what the runs cost so far: the settled amount for finished runs and the held reservation for runs in flight.settledCreditsandinFlightCreditssplit that sum. - A metered run that cost more than the workspace had left is charged up to the headroom, and the platform absorbs the rest. The totals report it as
platformAbsorbedCredits, andsettledCreditsminusplatformAbsorbedCreditsis what reached the budget. An approved-app markup the budget could not cover is absorbed too and reported apart asappMarkupAbsorbedCredits. - A report with more than 5,000 groups returns
truncated: true. Only the grouped rows are cut: the totals cover the whole range. - A plain member sees only their own runs, and grouping by member is refused for them. A workspace admin sees everyone and may filter by
userId. The organization report is for owners and organization admins. - CSV exports are UTF-8, RFC 4180, with CRLF line endings and no byte-order mark. A cell that starts with
=,+,-or@is quoted with a leading apostrophe so a spreadsheet cannot run it. Exports are limited to 10 per minute for each user and are recorded in the audit log. - When a member deletes their account, their run history goes with it, and the report shows a gap. Their workflows and projects stay with the workspace.
An instance without usage reports answers 404, and 503 billing_unavailable while reporting is not yet available.
Errors
| Status | Code | When |
|---|---|---|
| 400 | validation_error | A field is invalid, a cursor is malformed, the change is not allowed for that row, or a usage report has a bad date, time zone, range or grouping. |
| 400 | terms_required | Creating a school without acceptTerms: true. |
| 400 | not_org_member | Adding someone to a workspace who is not an active member of its organization. |
| 400 | token_workspace_mismatch | A token bound to one workspace, with a header naming another. |
| 400 | join_code_invalid | No such code, the code is disabled, or its workspace is archived. One answer for all three. |
| 400 | invitation_expired, invitation_revoked, invitation_accepted | The invitation is past its 14 days, revoked, or already accepted. |
| 400 | email_mismatch | The signed-in account's email is not the invited one. |
| 401 | unauthorized | No valid credentials. |
| 403 | insufficient_role | You are a member, but your role is too low for the action. |
| 403 | member_suspended | Your membership is suspended. |
| 403 | org_not_active | The organization is pending or suspended, and the action changes something. |
| 403 | workspace_archived | A write into an archived workspace. |
| 403 | not_a_member | The header names a workspace you cannot select. |
| 403 | domain_not_allowed | The organization admits only listed email domains. |
| 404 | not_found | No such organization, workspace or member, or one you are not a member of. |
| 404 | invitation_not_found | No invitation for that token, including one whose organization is not active. |
| 409 | name_taken | The slug you sent is in use. |
| 409 | already_a_member | The person is already in the workspace. |
| 409 | owner_cannot_leave | Transfer ownership before leaving. |
| 409 | has_active_workspaces | Archive every workspace before deleting the organization. |
| 429 | rate_limit_exceeded | Too many organizations created, join attempts, or CSV exports. |
| 429 | bulk_invite_cap_exceeded | The organization reached its daily invitation limit. |
| 503 | billing_unavailable | Usage reporting is not available on this instance yet. |
| 503 | audit_unavailable | A CSV export could not be recorded in the audit log, so it was refused. Try again. |
From the SDK, the CLI and MCP
- SDK:
client.organizationsandclient.workspacescover these endpoints, includingusage,usageRowsandusageCsvfor reports.client.me()carries the organization fields. - CLI:
nodaro organdnodaro workspace, for examplenodaro org invite <orgId> --email ada@school.example --workspace <id>andnodaro org usage <orgId> --from 2026-09-01 --to 2026-09-30 --group-by member --csv > september.csv. The invite command prints one line per address, with the link for any address that was not emailed. - MCP: the
list_workspacesandselect_workspacetools. The selection is remembered across sessions and checked again at each one. An OAuth app that uses these tools needs theworkspaces:readandworkspaces:writescopes.
Read The CLI and The SDK for the full command and method lists.
Frequently asked questions
Related
Workspaces
Authentication
Credits
Workflows
CLI
Last updated on
3D scenes
Generate, edit and render editable 3D clay scenes over REST, quote and run 3D Render Pro, and read scene revisions, assets and deliveries.
Credits
Read your Nodaro credit balance and history from the API, price models and runs before you start them, and see how reservations, refunds and pay-as-you-go work.