Workspaces
Every diagram belongs to one workspace. Each person has a personal workspace and can also belong to team workspaces, as an owner, a member or a viewer. Every API key, connected app and dashboard session acts in exactly one workspace and sees only that workspace’s diagrams.
Team workspaces are a beta, open to every account. They are billed from the start: Team costs $8 per paid seat a month or $80 a year, with at least 2 seats, and viewers and agents are free. Team’s features, and the management endpoints below, may change during the beta; the diagram API and MCP tools don’t change with them. GET /v1/auth/me says whether the account can create a team workspace, in features.teams.
One workspace per credential
- An API key acts in the workspace it was made in: the one selected in the dashboard, or the workspace of the key that made it. A connected app acts in the workspace chosen when it was allowed. The dashboard acts in the workspace selected in its switcher.
- No endpoint or tool takes a workspace parameter. To work in two workspaces, use a key or a connection for each. Connecting the same app again in the same workspace replaces its earlier connection there; its connections to your other workspaces stay.
- A diagram in another workspace answers
NOT_FOUND, as one that doesn’t exist does. Only in the dashboard does the answer name the other workspace, when you belong to it (workspace: { id, name }besideerrorandcode), so you can switch to it. - Keys are unique per workspace, so the same key can name one diagram in your personal workspace and another in a team workspace. Repeating your own create with the same key and content returns the existing diagram; a teammate’s create with a key the workspace already has answers 409
KEY_EXISTS, so read and edit that diagram instead. - Everyone in a team workspace can read every diagram in it. There is no per-diagram privacy inside a workspace: draft in your personal workspace and move the diagram in when it is ready.
Roles
| Owner | Member | Viewer | |
|---|---|---|---|
| Find, read, render, history, compare, one diagram’s export | Yes | Yes | Yes |
| Create, edit, delete, restore | Yes | Yes | No |
| Publish, create share links | Unless sharing is off | Unless sharing is off | No |
| Unpublish, list and revoke share links, report a problem | Yes | Yes | No |
| Move a personal diagram in | Yes | Yes | No |
| Export the whole workspace | Yes | No | No |
| Make keys, connect apps | Write or read | Write or read | Read only |
| See and revoke everyone’s keys and connected apps | Yes | No | No |
| Invite, invite link, domains, join requests | Yes | No | No |
| Roles, removing people, transfer, rename, sharing switch, deleting | Yes | No | No |
| Subscribe, billing portal | Yes | No | No |
| Usage and limits | Yes | Yes | Yes |
| Leave | Unless the last owner | Yes | Yes |
A team workspace can have several owners and always has at least one: removing, demoting or leaving as the last owner answers 409 LAST_OWNER. You are the owner of your personal workspace; the team-only actions answer 400 NOT_A_TEAM_WORKSPACE there.
Agents can’t manage a workspace. Inviting people, roles, domains, the sharing switch, moving a diagram and billing work only from the signed-in dashboard: an API key gets 403 SESSION_REQUIRED, and a connected app’s sign-in works only on /mcp.
Write and read access
Each API key and connected app has write or read access. What it may do is that access narrowed by its person’s role: a viewer’s write key acts read-only, and writes again if they become a member.
- Read access finds, reads (outline, scene JSON or Mermaid), renders, reads history and compares diagrams, downloads one diagram’s history, lists recently deleted diagrams, and sends feedback without a diagram. Anything that changes or shares a diagram, or reports a problem with one, answers 403
READ_ONLY, withreason:credentialfor a read key or app,rolefor a viewer. Over MCP the tool list is the same, anddiagram_deleteanddiagram_shareanswerREAD_ONLYbefore asking the person to confirm. - Keys.
POST /v1/auth/keystakesscope,writeorread, defaulting towritewhen the caller can write andreadotherwise. Asking forwritewithout write access answers 403READ_ONLY. A key made with a key gets that key’s workspace and a scope no wider than its own. A viewer can hold 3 keys in a workspace (403PLAN_LIMIT_EXCEEDEDwithmaximumAllowed: 3); viewers’ keys don’t count toward the workspace’sapiKeyslimit.GET /v1/billing/megives the cap aslimits.viewerApiKeys, and a viewer the keys they hold asinventory.viewerApiKeys. - Connected apps. A client that asks for the OAuth scope
diagrams:readalone gets read access;diagrams, or no scope, asks for write. On the consent page the person chooses the workspace and the access. A viewer, or a client that asked only fordiagrams:read, gets read whatever is chosen. - History names people, not keys. Each version records who saved it,
by(their name, else their email), and how,credential(session,api_keyoroauth), but never which key (diagram_history).
Joining a team workspace
An owner adds people in three ways. Each one needs the workspace to be on Team, by a subscription or a plan Zindex provides (409 SUBSCRIPTION_REQUIRED otherwise), and a workspace holds at most 1,000 people (409 WORKSPACE_FULL). Joining needs a signed-in account with a verified email.
- Email invitation. An owner invites an address as a member or a viewer. The invitation works for 7 days, and only for an account whose verified email is that address (403
INVITATION_EMAIL_MISMATCHotherwise). A member is a paid seat, charged when the invitation is accepted; if the subscription has lapsed by then, or the seat can’t be added, the person joins as a viewer and the answer carries anote. Inviting the same address again sends a new link and stops the old one. An address that can’t have an account, such as a disposable inbox, can’t be invited unless it already has one, since nobody can sign up there to accept (400EMAIL_NOT_ALLOWED). A workspace can send 50 invitations a day. - Invite link. An owner makes one link for the workspace, shown once. Anyone who opens it joins with the link’s role. Making a new link stops the old one, and owners can turn it off.
- Email domain. An owner lists a domain such as
acme.com, and people with a verified address there can join with the domain’s role: at once (auto), or when an owner approves (approval, the default, which emails every owner, saying what approving the person as a member would cost), and the approving owner can choose the other role. Domain join has the rules.
The invite link and each domain have a role, viewer (the default) or member, which owners can change at any time. Changing the link’s role keeps the same link.
- A viewer is free. An owner can make them a member later, which adds a paid seat.
- A member is a paid seat, charged when they join, as for an invitation. If the seat can’t be added, they join as a viewer, and the answer carries a
note. When someone joins as a member by themselves, with the link or by a domain onauto, every owner gets an email naming them and the seat. A workspace on a plan Zindex provides (workspace.complimentaryinGET /v1/billing/me) has no Stripe subscription: it is charged nothing, and no email is sent. - Approving a request takes a
role, which defaults to the domain’s.
Domain join
- A person qualifies when their account’s verified address is at exactly the listed domain. An address verified with a code sent to it, or through GitHub, qualifies. An address that came from Google sign-in qualifies only when Google reports the company’s Google Workspace domain (
hd) for that account and it matches the listed domain. A personal Google account doesn’t, and the answer is 403NOT_ELIGIBLEwithreason: "google_hd": sign in with the company’s Google Workspace account, or ask an owner for an invitation. - Public mail domains, such as
gmail.comoroutlook.com, can’t be listed (400PUBLIC_MAIL_DOMAIN). Invite those people by email. Disposable inboxes can’t be listed either, and their addresses can’t have an account. - An owner or member of the workspace must qualify for a domain before it can be listed (403
DOMAIN_NOT_HELD). - Signing up or signing in never joins anyone. The dashboard offers the workspaces you can join;
GET /v1/join/availablelists them. Joining another way, by invitation or link, settles a pending request. - Anyone who still has a verified address at the domain qualifies, including someone who has left the company. With
approval, an owner checks each request.
Moving a diagram in
POST /v1/diagrams/{id}/move with { "workspaceId": "..." } moves one diagram from your personal workspace into a team workspace where you are an owner or member. It keeps its ID, every version and its hash chain. A published diagram stays published and its share links keep working, so everyone in the team, and anyone with a link, can still see it.
- It goes one way, personal to team, one diagram at a time. Nothing moves a diagram out of a team workspace.
- It works only from the signed-in dashboard with your personal workspace selected. A key or connected app gets 403
SESSION_REQUIRED. - Afterwards your personal workspace’s keys and connected apps get
NOT_FOUNDfor the diagram; the team’s see it. - Nothing moves when the team already has a diagram with the same key (409
KEY_EXISTS; a key can’t be renamed, so delete one of the two), when the team’s sharing is off and the diagram is public or has share links (403SHARING_DISABLED; make it private and revoke its links first), when the team is at its diagram limit (403LIMIT_REACHED), or when you are a viewer there (403READ_ONLY).
Public sharing
A team workspace’s owners can turn public sharing off. While it is off, publishing a diagram and creating a share link answer 403 SHARING_DISABLED, over REST and MCP; making a diagram private and revoking links still work.
- Turning sharing off doesn’t take down anything already shared. Make all private unpublishes every diagram and revokes every share link in the workspace; it doesn’t turn sharing off.
- Share links stay anonymous: anyone who has one sees the images. No link opens for workspace members only.
- The switch doesn’t cover render links.
diagram_renderandGET /v1/diagrams/{id}/renderstill return signed image links that anyone holding them can open for 7 days, and anyone in the workspace, viewers and read keys included, can make them. - Each share link records who made it:
createdByinGET /v1/diagrams/{id}/links.
Seats, billing and limits
A team workspace is on Team or on Free (Limits). Team costs $8 a month or $80 a year for each paid seat.
- Each owner and member is a paid seat, with at least 2 per workspace. Viewers are free, and so are agents: a key or connected app acts as its person and costs nothing extra.
- Adding a seat is charged at once for the rest of the billing period. Removing one leaves a credit for the workspace’s later invoices, which isn’t paid out in cash. Seats follow roles; the billing portal doesn’t change them.
- Any owner can subscribe, open the billing portal and cancel. Pro is for a personal workspace and Team for a team one; checkout for the other answers 400
PLAN_NOT_AVAILABLE. - The owner who completes checkout pays. If you paid for a team workspace and stop being an owner (you leave, are removed, are made a member or a viewer, transfer ownership, or close your account), your card is removed from it and its billing contact moves to a remaining owner, the one who joined first; invoices already issued keep the details they were issued with. Every card on the workspace goes, including one another owner added; that owner adds it again. The confirmation says so before you act.
- Every remaining owner then gets an email asking them to add a card before the subscription renews, and Billing shows them “No card on file” with Add a card, which opens the billing portal. While the subscription is set to cancel, the email asks them to resume it and add a card, and Billing shows Resume first. Until a card is added, a seat added is charged with the renewal instead of at once. Once a card is there, Zindex sees it the next time an owner opens Billing or another signed-in page; the owner who last opened the portal then pays, or the owner whose visit found it if that owner is no longer an owner or nobody opened it. If no card is added, the renewal payment fails and the subscription is past due. A plan Zindex provides has no subscription and no card, so none of this applies.
- Limits are pooled: Team gives Pro’s diagrams, updates, renders and keys for each paid seat, shared by everyone in the workspace, so one busy agent can use up the month’s updates for everyone. The rate limit is per person in each workspace.
- When the subscription ends, the workspace has Free’s limits. Everyone keeps their role. Until it is subscribed again, nobody new joins by link or domain and no invitation can be sent; an invitation sent before it ended can still be accepted, as a viewer.
- Each person who joins as a member, by the invite link, a domain or an approval, adds a paid seat (Joining a team workspace).
- You can have one team workspace you created that isn’t paid for; creating another answers 409
UNPAID_WORKSPACE_EXISTS.
GET /v1/billing/me returns the selected workspace’s plan, pooled usage, limits and seats (paid, billed, minimum) to anyone in it, and its Stripe state to owners only, including workspace.subscriptionInterval (month or year; null without a subscription, or until Stripe has said) and, for a team workspace, workspace.paidBy (the owner who pays, or null while nobody does) and workspace.paymentMethodNeeded: { since, renewsAt } while it needs a card because its payer stopped being an owner, else null (renewsAt is null until Stripe has said). workspace.complimentary, for anyone in it, is true on a paid plan Zindex provides, with no subscription and no charge for seats.
Leaving, deleting and exporting
- Leaving. Leave, or be removed by an owner: your keys and connected apps for that workspace are revoked, and the diagrams you made stay. The last owner can’t leave (409
LAST_OWNER); make someone else an owner first. - Transfer. An owner makes someone else an owner and becomes a member or a viewer, or leaves.
- Deleting a team workspace. An owner types its name, then confirms from an email. Its diagrams with every version, its keys, connected apps, invitations and share links are deleted at once; its subscription is canceled with no refund for the rest of the period; everyone in it gets an email.
- Closing your account is refused while you are the last owner of a team workspace with other people in it (409
LAST_OWNER, listing them). A team workspace you are alone in is deleted with your account. In the others, the diagrams you made stay, and their history shows"former member". A team workspace you pay for keeps its subscription without your card (Seats, billing and limits). - Export.
GET /v1/workspace/exportdownloads every diagram in the workspace the request acts in (Export). In a team workspace only owners can, with a session or their own key (403OWNER_REQUIREDotherwise); everyone can download one diagram’s history.
Management endpoints
The dashboard calls these with its signed-in session, except the routes that take an emailed or linked token instead (the previews, accepting a link and confirming a deletion). They aren’t in the OpenAPI spec, and an API key gets 403 SESSION_REQUIRED. They take and return JSON. Errors from the routes in these tables are { error, code }, with details for some codes. A 404 NOT_FOUND carries reason: workspace when you aren’t in the workspace (the one in the path, or workspaceId in a join), or it doesn’t exist; otherwise what is gone, member, invitation, invite_link, domain, join_request, key or app.
Workspaces and people
| Method and path | Who | Does |
|---|---|---|
POST /v1/workspaces | Signed in, verified email | Create a team workspace: { name }, 1 to 64 characters. 201 { workspace }; the session switches to it |
GET /v1/workspaces/{id} | Anyone in it | { workspace }, with diagrams, how many live diagrams it holds (null if it can’t be counted) |
PATCH /v1/workspaces/{id} | Owner | Rename: { name } |
POST /v1/workspaces/{id}/select | Anyone in it | Switch the session to it; the next sign-in opens it too |
GET /v1/workspaces/{id}/members | Anyone in it | { members, seats } |
PATCH /v1/workspaces/{id}/members/{userId} | Owner | Change a role: { role }, owner, member or viewer. { member, seats } |
DELETE /v1/workspaces/{id}/members/{userId} | Owner, or yourself | Remove: { removed: true, keysRevoked, appsRevoked } |
POST /v1/workspaces/{id}/leave | Anyone in it | Leave, with the same answer |
POST /v1/workspaces/{id}/transfer | Owner | { toUserId, then }: then is member (the default), viewer or leave. { members, seats } |
POST /v1/workspaces/{id}/deletion-request | Owner | { confirmedName }, the exact name. 202 { ok, message, expiresAt }, and an email with the link that deletes it |
GET /v1/public/workspace-deletions/{token} | Anyone with the token | What the confirmation page shows: { workspace: { name }, expiresAt }. Deletes nothing; 410 TOKEN_INVALID once the link no longer works |
POST /v1/workspaces/deletion-confirm | The emailed token | { token }. { status: "deleted" }; 410 TOKEN_INVALID when the token is invalid, used or expired, or the owner who asked is no longer one; 502 BILLING_UNAVAILABLE with retryable: true when the subscription can’t be canceled, and nothing is deleted |
A workspace is { id, name, kind, role, plan, publicSharingDisabled, createdAt, people: { owners, members, viewers }, seats, subscriptionActive }, where seats is { paid, billed, minimum } for a team workspace and null for a personal one. A member is { userId, name, email, role, joinedVia, joinedAt, invitedBy, you, paysForWorkspace }. invitedBy, and addedBy on a domain, are { userId, name } (the person’s name, else their email), or null once that person has closed their account. GET /v1/auth/me adds workspace (the one the session acts in), workspaces (every one you are in, personal first) and features.teams.
A role change or transfer that adds a seat changes nothing and answers 502 BILLING_UNAVAILABLE when the subscription can’t be updated; try again in a minute.
Invitations and the invite link
| Method and path | Who | Does |
|---|---|---|
GET /v1/workspaces/{id}/invitations | Owner | { invitations }, the pending ones |
POST /v1/workspaces/{id}/invitations | Owner | { email, role }, role member (the default) or viewer. 201 { invitation }, and an email to the address |
DELETE /v1/workspaces/{id}/invitations/{invitationId} | Owner | Withdraw it |
GET /v1/public/invitations/{token} | Anyone with the token | What the invitation page shows: { workspace: { name }, invitedBy: { name }, role, email, expiresAt }, with the address partly hidden |
POST /v1/invitations/accept | The invited address, signed in | { token }. { workspace, role }, with note when you joined as a viewer instead; the session switches to it |
GET /v1/workspaces/{id}/invite-link | Owner | { enabled, role, createdAt, createdBy }. role is viewer or member; viewer while there is no link |
POST /v1/workspaces/{id}/invite-link | Owner | A new link, with an optional { role }: 201 { url, role, createdAt }, shown once. Without role the link keeps the role it had. The old link stops |
PATCH /v1/workspaces/{id}/invite-link | Owner | { role }: change the role, keeping the link. Answers as GET; 404 NOT_FOUND with reason: "invite_link" when there is no link |
DELETE /v1/workspaces/{id}/invite-link | Owner | Turn it off: { enabled: false }. Its role goes back to viewer |
GET /v1/public/join-links/{token} | Anyone with the link | { workspace: { name }, people, role } |
POST /v1/join-links/accept | Signed in | { token }. { workspace, role }, with note when you joined as a viewer instead; the session switches to it |
Accepting when you are already in the workspace answers 409 ALREADY_MEMBER with workspace.
Domains and join requests
| Method and path | Who | Does |
|---|---|---|
GET /v1/workspaces/{id}/domains | Owner | { domains }, each { domain, joinMode, joinRole, addedBy, createdAt } |
POST /v1/workspaces/{id}/domains | Owner | { domain, joinMode, joinRole }, joinMode approval (the default) or auto, joinRole viewer (the default) or member. 201 { domain } |
PATCH /v1/workspaces/{id}/domains/{domain} | Owner | { joinMode, joinRole }, either or both. { domain } |
DELETE /v1/workspaces/{id}/domains/{domain} | Owner | Stop listing it; pending requests stay for owners to decide |
GET /v1/join/available | Signed in | { workspaces, eligibility }: the team workspaces your address’s domain can join, each { id, name, domain, joinMode, joinRole, requested }, and whether you qualify, with reason domain, unverified or google_hd when you don’t. requested is true while your request waits for an owner; with auto you join at once |
POST /v1/join/domain | Signed in | { workspaceId }. With auto: { joined: true, workspace, role }, with note when you joined as a viewer instead, and the session switches to it. With approval: 202 { requested: true, requestId } |
GET /v1/workspaces/{id}/join-requests | Owner | { requests }, the pending ones, each { id, user: { userId, name, email }, domain, joinRole, createdAt }: joinRole is the domain’s, or viewer once it is no longer listed |
POST /v1/workspaces/{id}/join-requests/{requestId}/approve | Owner | Optional { role }, viewer or member, defaulting to the domain’s joinRole (viewer once the domain is no longer listed). Adds the person: { member }, with note when a member’s seat couldn’t be added and they joined as a viewer. Someone already in the workspace keeps their role |
POST /v1/workspaces/{id}/join-requests/{requestId}/decline | Owner | { declined: true }; the person can ask again |
Everyone’s keys and apps, and sharing
| Method and path | Who | Does |
|---|---|---|
GET /v1/workspaces/{id}/keys | Owner | { keys }: every active key in the workspace, whoever made it, with its scope and owner |
DELETE /v1/workspaces/{id}/keys/{keyId} | Owner | Revoke it: { revoked: true } |
GET /v1/workspaces/{id}/apps | Owner | { apps }: every connected app that can still act in the workspace |
DELETE /v1/workspaces/{id}/apps/{grantId} | Owner | Disconnect it: { revoked: true } |
GET /v1/workspaces/{id}/sharing | Anyone in it | { publicSharingDisabled, publicDiagrams, shareLinks } |
PUT /v1/workspaces/{id}/sharing | Owner | { publicSharingDisabled }, with the same answer |
POST /v1/workspaces/{id}/sharing/make-private | Owner | { unpublished, linksRevoked } |
The person whose key or app an owner revokes isn’t told. Everyone manages their own keys with POST /v1/auth/keys and DELETE /v1/auth/keys/{id}, and their own connected apps in the dashboard. The sharing endpoints are for team workspaces only.
Errors
| Code | Status | Means |
|---|---|---|
INVALID_REQUEST | 400 | A field is wrong; error says which |
NOT_A_TEAM_WORKSPACE | 400 | That applies to team workspaces only |
PUBLIC_MAIL_DOMAIN | 400 | Anyone can get an address at that domain (public mail or a disposable inbox), so it can’t be listed |
EMAIL_NOT_ALLOWED | 400 | The address can’t have an account and has none; reason is disposable for a disposable inbox |
PLAN_NOT_AVAILABLE | 400 | Pro is for a personal workspace and Team for a team one |
AUTH_REQUIRED | 401 | Sign in first |
SESSION_REQUIRED | 403 | Sent with a key or connected app; use the signed-in dashboard |
OWNER_REQUIRED | 403 | Only an owner of the workspace can do that |
EMAIL_NOT_VERIFIED | 403 | Verify your email at https://zindex.ai/verify-email first |
INVITATION_EMAIL_MISMATCH | 403 | The invitation is for another address; sign in with that one |
DOMAIN_NOT_HELD | 403 | No owner or member qualifies for the domain yet |
NOT_ELIGIBLE | 403 | You don’t qualify to join by domain; reason is domain, unverified or google_hd |
NOT_FOUND | 404 | No such workspace, person, invitation, invite link, domain, request, key or app that you can see; reason says which |
LAST_OWNER | 409 | The workspace would have no owner; make someone else an owner first |
SUBSCRIPTION_REQUIRED | 409 | The workspace needs an active subscription before anyone can join |
ALREADY_MEMBER | 409 | That person is already in the workspace |
WORKSPACE_FULL | 409 | The workspace has 1,000 people |
UNPAID_WORKSPACE_EXISTS | 409 | You already created a team workspace that isn’t paid for; workspace names it |
JOIN_REQUEST_PENDING | 409 | You already asked; an owner will decide |
DOMAIN_EXISTS | 409 | The workspace already lists that domain |
INVITATION_INVALID | 410 | The invitation or link expired, was withdrawn or reset, or was used |
TOKEN_INVALID | 410 | The deletion link is invalid, used or expired, or the owner who asked is no longer one |
RATE_LIMITED | 429 | 50 invitations in 24 hours |
BILLING_UNAVAILABLE | 502 | The subscription couldn’t be updated, so nothing changed; try again |
EMAIL_UNAVAILABLE | 502 | The invitation or confirmation email couldn’t be sent, so nothing was sent or requested; try again |