Symbol Docs

Preview API

Symbol's current API version, what it covers, and how it will change.

Symbol's HTTP API is currently in preview. The preview namespace is the current version of the API, not a beta branch that runs alongside a stable release. There is no v1 yet. When Symbol promotes the surface to v1, the preview routes will continue to exist for a deprecation window so that integrations can migrate at their own pace.

Here are the API docs: https://symbol.chat/docs/api

What "preview" means

  • Preview is the current version. Every public, in-scope resource endpoint is served under /api/preview/*.
  • Preview is free. Users on the preview tier self-select as early adopters who accept aggressive iteration in exchange for shaping the contract.
  • Schemas, error shapes, and field semantics may change during the preview window, but never silently. Breaking changes are announced through the API stability policy (see Stability & promotion below) before they ship.
  • The URL prefix is the loudest possible signal that the contract is still moving. Once a route is promoted to /api/v1/*, breaking changes there require a 12-month deprecation window.
  • No SLA yet. Symbol does not yet publish uptime or latency commitments for preview.

Versioning model

/api/preview/*   ← current version (this is where you integrate)
/api/v1/*        ← future stable contract
/api/v2/*        ← later stable contract

Versions coexist under different namespaces. We never replace a route in place. When v1 lands, a /api/preview/capsules/:id route is copied to /api/v1/capsules/:id and the two are allowed to diverge. Preview may keep receiving fixes during the deprecation window.

The architecture is intentionally low-magic:

  • URL == file location. The route file's path on disk matches the URL served: no next.config.ts rewrite layer, no version-aware middleware.
  • Thin handlers, shared services. Route handlers stay tiny and delegate to a shared service layer. Two version routes can share one service function.
  • Promotion is a copy. Promoting preview → v1 is a physical file copy plus a registered code change in the OpenAPI spec, not a rename.

What's in the preview namespace

The preview namespace covers Symbol's resource endpoints, the routes that represent the Symbol data model and account operations. Examples:

  • /api/preview/capsules/*, /api/preview/types/*, /api/preview/projects/*
  • /api/preview/organizations/*, /api/preview/share/*, /api/preview/collections/*
  • /api/preview/invitations/*, /api/preview/attachments/*
  • /api/preview/mentions/*, /api/preview/references/*
  • /api/preview/library/*, /api/preview/search/*
  • /api/preview/workspaces/*
  • /api/preview/sync/* (the change feed and snapshot a local store refreshes from)
  • /api/preview/billing/* (non-webhook), /api/preview/gdpr/*, /api/preview/support/*

The OpenAPI document is the authoritative inventory of the public preview surface. Endpoints not listed there are not part of the public contract and may move, change, or disappear without notice, including any URLs discoverable by inspecting the application but not advertised in the spec.

Workspace scoping (?ws=)

List/read endpoints accept an optional ws query parameter that selects which workspace to read from:

  • omitted: the caller's personal workspace, for session and personal API-key auth (the default). For an organization API key, an omitted ws resolves to the key's bound organization workspace instead. An org key never reaches the personal workspace.
  • ws=<organization-id>: a single organization workspace the caller belongs to.
  • ws=all, cross-workspace discovery: the union of every workspace the caller can reach (personal plus each accessible organization), reported in one response.

ws=all is accepted only on the discovery routes:

  • GET /api/preview/types
  • GET /api/preview/capsules
  • GET /api/preview/search
  • GET /api/preview/projects
  • GET /api/preview/sync/snapshot (where all is the default)

On every other route ws=all is rejected with 404 Workspace not found, exactly like an unknown workspace id. The widened mode is contained to the five routes above. For an organization API key, ws=all collapses to the key's bound organization (it never widens beyond it), and a credential bound to a subset of workspaces (per-key workspace binding) only ever discovers inside that binding.

Credentials limited to workspaces

A personal API key, a mobile key, or an OAuth or MCP connection can be limited to some of your workspaces. The limit holds for every read and write, not only for listings:

  • Reading, editing, deleting, archiving, moving or sharing a capsule, Type, Collection or project outside the limit answers the same 404 a nonexistent id gets. Batch routes give that item the same per-item result a random id gets, and in-limit items in the same request still succeed. Listing or revoking the share links of an out-of-limit resource answers the empty list, or the 404, that a random id gets.
  • Account routes that act on one fixed workspace refuse a credential whose limit leaves that workspace out, with 403 and reason credential_workspace_bound. That covers snapshots (personal only), the account export (GET /api/preview/export?scope=account), and the GDPR data export, which any limited credential is refused because it covers the whole account. The organization export (scope=org) answers an organization outside the limit with the 404 a non-member gets. Sign in, or use an unrestricted key, for these.
  • Administering an organization outside the limit answers as a non-member. Its members, invitations, activity, governance, settings, organization API keys, encryption, marketplace, ownership transfer and deletion under /api/preview/organizations/{id}/ all answer exactly as they would for someone who is not a member of that organization (404 Organization not found, or 403 Not a member of this organization on the encryption routes), whatever role the key's owner holds there. GET /api/preview/organizations and GET /api/preview/user/organization-management list only the organizations inside the limit. Invitations addressed to you stay readable and answerable with any key.
  • Saving a shared capsule copies it into your personal library, so a key whose limit leaves out personal gets unsupported_caller.
  • Opening a share link works the same for any holder of the link, whatever the credential's limit: the link, not the key, authorizes that read.

The database applies the same limit on its own, from the credential that made the request.

Creating keys from a key

A credential can only create a credential weaker than itself:

  • An OAuth or MCP connection cannot create API keys at all: 403, reason credential_cannot_mint_keys. An organization API key cannot either; it gets 403, reason organization_key_not_permitted (see "Organization API keys" below).
  • A personal or mobile key can create a key, or rotate one of your personal keys, only with scopes it holds (403 insufficient_scope, naming the missing scopes), inside its own workspace limit (403 workspace_binding_exceeded), and expiring no later than itself (400 expiry_exceeds_credential). A rotation is held to the scopes, limit and expiry of the key being rotated. A limited key that names no workspaces creates a key with the same limit. A key creating an organization key must give it an expiry.
  • Only a personal key can be the key that gets rotated: asking to rotate a mobile key or a connection's id answers 404, whoever asks.

A request the database refuses on these grounds answers 403, reason credential_ceiling_exceeded.

Connections made from a key

Symbol Bridge tokens obtained from a key (POST /api/preview/oauth/desktop-token) follow that key. They carry its workspace limit and never outlive it, and they end when the key is revoked, expires, is deleted, or is replaced by name. After a rotation they end when the old key's 24-hour grace period does. Each one shows in Connected Apps as its own entry, named after the key, apart from any browser sign-in, and revoking one never signs the other out.

Bridge connections made before this rule existed could not be told apart from a browser sign-in, so all of them were signed out once when it shipped. Reconnecting gives a connection that follows its key, or a browser sign-in.

Organization API keys

An organization API key acts for its organization, not for the person who created it. It reads and writes the content of its own organization's workspace within its scopes, and nothing else. It is refused on every endpoint that acts for a person or administers an organization:

  • the personal account: profile, credentials, API keys, OAuth grants, encryption settings, the data export, snapshots and the audit log;
  • billing;
  • the inbox and messages;
  • creating, listing and revoking share links;
  • accepting or declining invitations;
  • organization administration: members, invitations, API keys, ownership transfer, governance, renaming and deletion.

Owners and Admins manage organization keys (list, create, hand over with PATCH /api/preview/organizations/{id}/keys/{keyId}, and revoke); members and viewers get 403 with reason insufficient_role. Each key has a holder, one member of its organization, and content the key creates is attributed to them. What the key may do comes from its organization and its scopes, not from the holder's role. When the holder leaves the organization or deletes their account, the key is revoked and answers 401 with code key_revoked, and re-adding them does not bring it back. A key whose holder left before keys ended with their holder answers creator_not_member instead. Hand a key to another owner or Admin before its holder leaves to keep it working with the same secret.

The delete and share scopes let a key delete, or change the visibility of, content in its organization that members can see, the same content an owner or Admin manages. A member's Private (Self) content, and content shared only by link, stay out of reach. Publishing skills and agents stays a person's act.

A refused call on the key's own organization, or on an endpoint with no organization in its path, answers 403 with the problem reason organization_key_not_permitted. A call on any other organization's endpoints answers 404, the same response a non-member gets. Use a personal credential (a session or a personal API key) for these endpoints.

Over MCP, an organization API key connection works on its organization's content, and get_account_info describes the organization rather than a person: principal is organization_api_key, organization names the organization, and email and member_since are null.

Preview signals

Beyond the URL prefix, preview is signalled in several places so it's hard to miss:

  • The OpenAPI document marks every preview operation with an x-preview: true extension (see emission convention)
  • The hosted API reference shows a banner: "Preview API: subject to breaking change without notice"
  • Generated TypeScript and Dart clients carry a banner in the file header
  • The Postman collection name is suffixed (preview)
  • MCP tool descriptions note the preview surface

x-preview OpenAPI extension

Every operation served under /api/preview/* emits x-preview: true at the operation level in the generated OpenAPI document. Tooling consumes it to render banners, gate generated-client release channels, and drive the public changelog.

Example:

paths:
  /api/preview/capsules/{id}:
    get:
      summary: Get capsule
      x-preview: true
      responses:
        "200": { ... }

Stable (/api/v1/*) operations do not emit this extension. The emission rule is path-derived: the OpenAPI generator inspects each operation's URL prefix and stamps x-preview: true on every operation served from /api/preview/*. No per-route annotation is required, so the flag cannot drift from the URL. A unit test asserts the invariant against the generated artifact.

Error envelope

All error responses use RFC 9457 Problem Details. The body is JSON with a Content-Type: application/problem+json header and the following canonical members:

MemberTypeNotes
typestringURI identifying the problem type (e.g. https://symbol.chat/problems/not-found). Stable and machine-readable.
titlestringShort human-readable summary, suitable as a UI fallback. Stable per type.
statusintegerMirrors the HTTP status code.
detailstringHuman-readable explanation specific to this occurrence.

Endpoints may include extension members at the top level alongside the canonical fields. Common extensions:

ExtensionWhere usedDescription
errors422 from request schema validationArray of { path, rawPath, message, code } describing each Zod validation issue. path is a dotted string (items.0.name); rawPath preserves segment types so clients can disambiguate indices from numeric keys (["items", 0, "name"]).
unknown_field422 from rejectUnknownFieldsName of the field that wasn't accepted.
suggestion422 from rejectUnknownFieldsSuggested correct field name when one is known.
capsule_count409 when deleting a Type that has capsulesHow many capsules block the delete.
code409 from DELETE /api/preview/types/{id} or batch moveMachine-readable conflict cause. From the Type delete: strategy_required (a non-empty Type needs ?strategy=cascade or ?migrateTo=<id>), impact_confirmation_required (the delete affects references, other Types' field constraints, collections, or share links; retry with ?confirm=true), exposure_requires_confirm (retry the migrate with ?confirmExposure=true), has_capsules or other_users_capsules (capsules remain that the delete cannot remove), or type_is_log_target when that refusal follows a committed migrate. From the Type delete's migrate: log_capsule_source (a log Type's capsules cannot be migrated) or owned_log_type_mismatch (capsules keep logs in a different log Type than the target is bound to). From batch move: exposure_requires_confirm or log_type_target. impact_confirmation_required, and the 409 (rather than 400) for other_users_capsules, apply to every caller from the effective date of the breaking change in the API changelog. Until then they apply only to a caller that sends confirm=true or confirm=false, so sending ?confirm=false opts in now.
code422 from POST /api/preview/capsules or PUT /api/preview/capsules/{id}Machine-readable cause of a refused body write. empty_body_on_templated_type: content_md is "" on a capsule whose Type has an output_template. Send content_md: null (update) or omit it (create) to use the template, send body text, or send the boolean authored_blank: true alongside "" to keep an empty authored body. The refusal applies to every caller from the effective date of the breaking change in the API changelog; until then such a write is accepted and the response's warnings carries an entry beginning empty_body_on_templated_type: that names the date, and sending authored_blank: false opts into the refusal now. attachment_credential_in_body: the body introduces an attachment access link (/api/preview/attachments/public/<key>?cat=...) for a file that is not this capsule's; nothing is written. Upload the file to this capsule and embed it by file name, or paste the embed_markdown from the upload reply. A link of that kind already stored in the body is left as it is and reported in warnings instead. On PUT this refusal arrives in the { error: { code, message, details } } envelope with details.reason set to attachment_credential_in_body, as embedded_capsule_body does.
code409 from PUT /api/preview/types/{id}rollover_requires_confirm: the update changes rollover on a log Type that already holds dated buckets. Existing buckets keep their names and entries and only entries appended from now on follow the new policy, so the period in progress is split between two buckets. The body carries bucket_count, current_rollover, new_rollover, current_period_key (the bucket an entry appended now lands in under the current policy) and next_entry_period_key (under the new policy, null for manual, where every append must name a log capsule). Retry with confirm_rollover: true to apply the change. The refusal applies to every caller from the effective date of the breaking change in the API changelog. Until then it applies only to a caller that sends confirm_rollover: true or confirm_rollover: false, so sending confirm_rollover: false opts in now.
code422 from POST /api/preview/types or PUT /api/preview/types/{id}Machine-readable cause of a refused output template. Both routes run the same check and return the same code and detail. output_template_dangling_tokens: a {{token}} or {{#section}} names a field the Type does not declare; dangling_tokens lists those names. output_template_malformed_sections: a section is never closed, a closing tag has no opening tag, or two sections overlap; malformed_sections lists each as { name, tag, problem }, where problem is unclosed, unopened or crossing. Nothing is written. Declare the field, or edit or clear output_template in the same request. See output templates.
capsules_migrated, migrated_refs, migrated_aliases, dropped_aliases409 from DELETE /api/preview/types/{id}?migrateTo=<id> when the delete is refused after the migrate committedWhat already moved to the target: the count, the old to new ref and alias maps, and the aliases that could not travel. The moved capsules stay in the target; only capsules created concurrently remain in the source.
code409 from DELETE /api/preview/capsules/{id} or POST /api/preview/capsules/batch/deletecitations_confirmation_required: other capsules cite the capsule (or one in the batch), and their mentions of it would stop pointing anywhere. Nothing was deleted. Retry with ?confirm_citations=true (single delete) or "confirm_citations": true in the body (batch delete). POST /api/preview/capsules/batch/delete-impact reports the same count and sample before you delete. The refusal applies to every caller from the effective date of the breaking change in the API changelog. Until then it applies only to a caller that sends confirm_citations (true or false), so sending confirm_citations=false opts in now.
citing_count, citing_sample409 citations_confirmation_requiredHow many live capsules cite the capsules being deleted, and up to five of them as { id, title, ref }. Only capsules you can read are counted or named.
impact409 strategy_required / impact_confirmation_requiredWhat the delete would affect beyond the Type's own capsules, as returned by GET /api/preview/types/{id}/delete-impact: inbound references and mentions, other Types' reference-field constraints, collections, and usable Type/collection share links, each a count plus a small sample. On a migrate it also carries migrate_effects (as GET /api/preview/types/{id}/delete-impact?migrateTo=<id> returns it): capsules that leave a required field of the target unmet and will be marked incomplete, template bodies that change, go blank or render as fields, and owned logs that detach. Any of these needs ?confirm=true.
affected409 exposure_requires_confirm from batch move and delete-migrateArray of { kind, id, name, slug, visibility } public share sources at the target Type through which the move would publish the capsules. Retry with confirm_exposure: true (batch move) or ?confirmExposure=true (delete-migrate) to proceed.
will_publish_count409 exposure_requires_confirm (as above)How many plaintext capsules the move would publish through those share sources.
audience, live_link_count, dropped_collections, unlisted_count, message409 exposure_requires_confirm from the move-to-workspace routesWho in the organization will see the content ({ kind: "organization", name, member_count, access: "edit" | "view" | "none" }), how many live share links on it keep working, the collections a move leaves (per capsule), how many publicly listed capsules stop being listed, and the same facts as one sentence. will_publish_count is also present. Retry with confirm_exposure: true. The project route adds type_map (the proposed per-Type mapping) and skipped (capsules that stay behind).
actions409 already_has_access from the save routes[{ kind: "personal_copy", capsule_id }] when the saver can read the capsule in the app: POST /api/preview/capsules/{capsule_id}/duplicate with { "target": "personal" } makes the copy instead.
reasonMember-visible 403 Problem Details denial (see below)Machine-readable denial cause: insufficient_role, owner_only, or visibility_denied. The legacy { "error": "…" } 403s (MCP-access and managed-account, below) carry no reason.
code403 API key scope denial (see below)insufficient_scope: the API key lacks the scope the action needs.
code409 from POST /api/preview/share or PUT /api/preview/share/visibilityencrypted: encrypted content (a capsule, a capsule in an encrypted Type, an encrypted Type, or a collection of one) can't be shared by link or made public, because link holders and visitors have no key to read it. Every share route answers this one status and message.
error_id400 or 500 from an unexpected failureCorrelation id to quote to support. A 400 carrying error_id means a value in the request was not in the expected format and was caught after validation rather than before it.

Example (validation failure):

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://symbol.chat/problems/unprocessable-entity",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "Validation error: title required at \"title\"; expected number, received string at \"age\"",
  "errors": [
    { "path": "title", "rawPath": ["title"], "message": "title required", "code": "too_small" },
    { "path": "age", "rawPath": ["age"], "message": "expected number", "code": "invalid_type" }
  ]
}

Example (unknown field):

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://symbol.chat/problems/unprocessable-entity",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "Field 'content' is not accepted on this endpoint; did you mean 'content_md'?",
  "unknown_field": "content",
  "suggestion": "content_md"
}

Example (unknown query parameter). List and search endpoints refuse a query parameter they do not read, instead of ignoring it and answering as if the filter had applied:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://symbol.chat/problems/bad-request",
  "title": "Bad Request",
  "status": 400,
  "detail": "Unknown query parameter 'tag'; did you mean 'tags'?",
  "unknown_param": "tag",
  "suggestion": "tags"
}

Standard problem types

Statustype URItitle
400https://symbol.chat/problems/bad-requestBad Request
401https://symbol.chat/problems/unauthorizedUnauthorized
403https://symbol.chat/problems/forbiddenForbidden
404https://symbol.chat/problems/not-foundNot Found
409https://symbol.chat/problems/conflictConflict
422https://symbol.chat/problems/unprocessable-entityUnprocessable Entity
500https://symbol.chat/problems/internal-server-errorInternal Server Error

The type URIs are stable identifiers. They don't have to resolve to documentation, but they are unique and won't be repurposed. Clients should prefer matching on type over parsing detail.

Permission denials

When a request is refused for authorization reasons, the status distinguishes who is being refused, so a client can tell a permission problem apart from a missing object:

  • A verified organization member (or a matching bound organization API key) who is refused an action on a resource they can already see (by role, visibility, or ownership) receives 403 https://symbol.chat/problems/forbidden with an actionable detail and a machine-readable reason extension member (insufficient_role, owner_only, or visibility_denied).
  • A non-member, an organization API key bound to a different organization, an owner-only action against someone else's personal resource, and an unauthenticated caller all receive the uniform 404 https://symbol.chat/problems/not-found. This is deliberate: the 404 never discloses whether the resource or organization exists, so it cannot be used to enumerate private content.

The boundary is verified membership: a denial is a 403 only when the caller's membership of the resource's organization has been established. The policy is fail-closed. Anything not proven member-visible stays a 404.

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://symbol.chat/problems/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "You don't have permission to modify this capsule. Ask an organization admin if you need access.",
  "reason": "insufficient_role"
}

An API key that lacks the scope an action needs (for example a read key updating, archiving, or deleting a capsule) receives 403 https://symbol.chat/problems/forbidden with code: "insufficient_scope", but only on a resource that same key can read. Asking for a resource the key cannot read, or an id that names nothing, still gives the uniform 404, so the scope answer never discloses that something exists.

Some operations need more scopes than the edit they look like. Deleting a capsule or Type version is open to whoever may restore that version, but a key must hold delete as well as update, because the version is gone for good. A snapshot restore with {"dryRun": true} writes nothing and needs only read; a real restore removes, overwrites and re-adds content, so a key must hold delete, update and create. A key missing one gets the 403 insufficient_scope answer naming it before the snapshot is looked up, so the answer is the same for every snapshot id and no restore allowance is spent. Deleting a log entry changes its log, so a key needs update as well as delete. Duplicating a capsule writes a new one from a capsule the caller may edit, so a key needs create as well as update.

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://symbol.chat/problems/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "This API key lacks the update scope.",
  "code": "insufficient_scope"
}

On the resource routes, a path or query id that is not a well-formed UUID is refused with 400 before any lookup, so the answer is the same whether or not a resource with a similar id exists.

Two 403 shapes predate this and are unchanged: an organization member without MCP access enabled, and a member of an organization that requires a managed account, each receive a legacy { "error": "…" } 403 with actionable copy.

A fourth reason, share_required, refuses a write that would publish a capsule without the right to share it: adding it to a Collection that is shared by link or listed publicly (POST /api/preview/collections/{id}/capsules), or moving it into such a Type. The batch move reports it per item as the failure code share_required. An API key without the share scope gets the existing 403 with code: "insufficient_scope" (and per-item insufficient_scope on the batch move); a caller who cannot see the capsule at all gets the uniform 404. Nothing is added when any item in the request is refused.

  • The reusable link. POST /api/preview/share with no expiresAt, no maxViews and no new returns the resource's one reusable link, minting it only the first time; reused is true when it already existed. Send new: true, an expiry or a view limit to mint a separate link. The reply is { token, share_url, shareToken, reused }: send share_url; token is the raw value, shown so an integration can store it.
  • Link rows. Rows from GET /api/preview/share (and shareToken) carry primary, share_url (null for a link made before links were reusable, for the listing link of a Public item, and for an organization owner or Admin viewing a member's link) and never the token hash or the creator's id. A link on an organization's item reads state: "suspended" with is_live: false while the organization does not allow links outside it: it is not revoked, and it opens again if the policy is lifted.
  • Your links. GET /api/preview/share/links?cursor&limit&state=live|all returns { links, next_cursor }: every link you created, newest first, with resource_title and resource_path. live, the default, lists links that have not expired or run out of views, paused ones included so you can still revoke them; all adds the expired and used-up ones. It never lists anyone else's links.
  • Revoking many. POST /api/preview/share/links/revoke takes exactly one of { ids } (1 to 100), { all: true } (up to 500 per call) or { resourceType, resourceId }, and answers { revoked, skipped_listing, not_found, has_more }. A link you cannot reach counts under not_found, exactly like one that does not exist. A Public item's listing link is never revoked here: unpublish it with PUT /api/preview/share/visibility. With { all: true }, listing links never take one of the 500 places, so calling again while has_more is true always reaches the next links; skipped_listing counts the ones left alone.
  • Who can see it. GET /api/preview/share/visibility adds live_link_count and, for the resource's owner, publishing: { public_profile_enabled, has_username, eligible, blocked_reason }. It answers a key with either the read or the share scope.
  • Publishing. PUT /api/preview/share/visibility with public accepts enable_public_profile: true to turn the owner's profile on in the same request. A refused publish names the missing step:
StatuscodeExtra fieldsMeaning
409username_requirednext_step: "claim_username", claim_pathClaim a username first, in Settings (it needs a browser).
422public_profile_requirednext_step: "enable_public_profile"The profile is off. Ask again with enable_public_profile: true.
409publish_blockedblocked_reasonemail_unverified, account_flagged or org_policy.

Library sharing

A Type or Collection shared by link, or listed publicly, publishes the capsules its owner created in it that the workspace can already see: in an organization a capsule whose own visibility is self is never published, and any capsule whose owner kept it out of library links is excluded. Encrypted and archived capsules are never published.

A project is a share kind too (resourceType: "project"). A project link publishes, under the same rule, the capsules in the project that the project's owner created, and its page groups them by Type. A project is shared by link only:

  • POST /api/preview/share accepts resourceType: "project". The project's owner (while a non-viewer member of its organization) and an organization owner or Admin over a team-visible project may mint one; anyone else gets the uniform 404.

  • PUT /api/preview/share/visibility accepts project for private and unlisted; public answers 422 with code: "not_publishable".

  • GET /api/preview/share, GET /api/preview/share/visibility and GET /api/preview/share/resolve (resource_type: "project") accept and return the new kind.

  • POST /api/preview/projects/{id}/capsules, POST /api/preview/capsules/batch/assign-to-project and a capsule update that sets project_id, into a project with a live link, need the right to share each capsule the link's owner created (share_required, as for a shared Collection); a teammate's capsule, which the link never publishes, needs nothing. Nothing is added when any item is refused.

  • POST /api/preview/capsules with a project_id the caller shares by link needs the share scope on an API key (insufficient_scope); a Type's default project the caller shares by link is not applied without it, and the capsule is created outside it.

  • Minting a project or Collection link is refused in a read-only organization workspace, as for a capsule or Type.

  • Deleting a project revokes its links.

  • GET /api/preview/share/exposure?resourceType=type|collection|project&resourceId=<id> returns { capsule_count, exposure, publishes_your_capsules }: capsule_count is how many capsules a link on the resource publishes (or would publish if the caller shared it now; 0 for anyone but the owner), exposure is the resource's live state (none, link or public), and publishes_your_capsules is true when a link is live, the caller owns it, and a capsule the caller creates there would be published: it is false under an organization Type that is Private or link-only, because a capsule created there starts Private. Requires read on the resource; anyone else gets the uniform 404.

  • POST /api/preview/share for a type, collection or project, and PUT /api/preview/share/visibility to unlisted or public on one, add exposes_capsules: the count the link publishes after the write.

  • Collection reads (GET /api/preview/types/{id}/collections and the single read) carry exposure: none, link or public.

  • PUT /api/preview/capsules/{id}/permissions accepts in_library_share (boolean) alongside or instead of visibility, under the same share authorization; false keeps the capsule out of every library link. Sent together, both are written at once: a refused request changes neither. The GET (and the PUT response) return in_library_share and shared_since: when the capsule last left self (ISO 8601), or null. Version history reads (GET /api/preview/capsules/{id}/versions and /versions/{n}) show a caller other than the owner only versions changed at or after shared_since; an earlier version answers 404. The permissions GET also returns effective: { can_update }: whether the caller may edit the capsule. It is the caller's own access, which can be narrower than the capsule's organization access level: a Viewer reads false on an org_edit capsule.

Sharing with people

Per-person grants (issue #2267) share a capsule, Type or Collection with named people by email, at view or edit.

  • GET /api/preview/grants?resourceType=capsule|type|collection&resourceId=<id> returns { grants: [...] }, each with id, access, email, name, username and pending (true until the person first opens Shared with me). Requires share on the resource, the same authority as minting a link; anyone else gets the uniform 404.
  • POST /api/preview/grants with { resourceType, resourceId, email, access } (access defaults to view) answers 201 { status: "invited", grant } whether or not the address belongs to an account. Sharing again with someone who already has access changes their access instead. Refusals are 422 with a code: encrypted_not_shareable, not_org_member (organization content goes only to its members), role_ceiling (edit for a member who is a viewer), self_grant, or grant_limit (50 people per item). Rate limited by grant_create.
  • PATCH /api/preview/grants/{id} with { access } changes one person's access (owner side only).
  • DELETE /api/preview/grants/{id} removes access. The owner side removes anyone; the person it was shared with removes themselves. Anyone else gets 404.
  • GET /api/preview/shared-with-me lists the caller's own grants, newest first: { items: [{ grant_id, resource_type, resource_id, title, owner_name, access, shared_at, url }], hasMore }. Page with before and before_id (the shared_at and grant_id of the last row).

A person a capsule is shared with reads it through the ordinary capsule routes (GET /api/preview/capsules/{id} and its references, backlinks, graph, bundle, versions and attachments). Versions are listed from the moment it was shared. With edit, PUT /api/preview/capsules/{id} accepts title, summary, content_md, authored_blank, field_values and client_updated_at only; any other key answers 403 with code grant_edit_scope. Every other write answers 404 for them. Shared content never appears in the caller's own listings, search, counts or sync feed.

Liked items

  • GET /api/preview/user/likes?cursor&limit lists the caller's own likes, newest first, as { items, next_cursor }. Each item is { capsule_id, liked_at, state, title, url }, where state is the first that holds right now: public (url is the public address), readable (the caller can read it in Symbol; url is /capsules/<id>), link (the share link it was liked through still opens it; url is /liked/<id>, which counts a view of that link when opened) or unavailable (title and url are null, and nothing else about the item is returned). Pass next_cursor back as cursor for the next page; limit is 1 to 50 (default 20). Requires the read scope. An organization API key is refused: likes belong to a person.
  • DELETE /api/preview/user/likes/{capsuleId} removes the caller's own like, including one whose page or link is gone, and answers { capsule_id, liked: false } whether or not a like existed. Requires the update scope.

The web app's visitor report route (POST /api/preview/public/reports) is not part of the public API.

Exempt routes

A small set of external-contract routes use a different error envelope and are out of scope for the Problem Details migration:

  • OAuth 2.0 token / client routes (/api/oauth/*) emit { "error": "invalid_grant", "error_description": "..." } per RFC 6749 §5.2.
  • NextAuth callback URLs (/api/auth/[...nextauth]) follow NextAuth's internal contract.
  • Stripe webhooks (/api/billing/webhook) follow Stripe's required envelope.

First-party clients (Flutter, MCP) tolerate both shapes during the rollout of the per-entity migration: they read detail first and fall back to error_description / error when the body is from an exempt route.

Moving and copying into a team

Personal content can be moved or copied into an organization the caller belongs to with a role that adds content (not viewer).

  • POST /api/preview/capsules/batch/move-to-workspace with { capsuleIds, targetWorkspaceId, targetTypeId, mode?: "move" | "copy", confirm_exposure? } (at most 100 ids). Each capsule must be one the caller created in their personal workspace, plaintext, not archived and not a log; others are reported per item in failed. A move keeps the capsule's id (its links, likes, saves, versions and attachments keep working), gives it a new ref in the target Type, joins it to the organization with the Type's visibility, and keeps its old @type/N ref and alias resolving for any reader who can read it there. Each item in succeeded names the source id, the capsuleId now in the team (the copy's for a copy), its new ref and alias, and droppedCollections. Encrypted capsules answer encrypted_not_movable.
  • POST /api/preview/projects/{id}/move-to-workspace with { targetWorkspaceId, typeMap?: { "<sourceTypeId>": "<targetTypeId>" | "create" }, mode?, confirm_exposure? } moves (or copies) a personal project and its capsules in one step. A source Type left out of typeMap maps to an organization Type with the same slug and fields the caller can add to, otherwise to a new Type created with the same fields, template and guidance. The project becomes org_view. Capsules that can't move stay personal and leave the project, listed in skipped.

Both answer 409 exposure_requires_confirm until confirm_exposure: true is sent, and nothing changes before then. A non-member's target organization is the uniform 404; a viewer gets the member-visible 403 with reason: "insufficient_role".

Duplicate to your personal workspace. POST /api/preview/capsules/{id}/duplicate accepts an optional body { "target": "personal" }. The copy lands in the caller's personal workspace, Private, in a Type of the source's shape; reading the source is enough, so a view-only member can take an editable copy. Without the body the copy lands beside its source and needs the write right, as before. Encrypted sources answer 422 encrypted_not_copyable.

Saving into a workspace. Both save routes (POST /api/preview/share/{token}/save and POST /api/preview/public/save) accept workspace_id: null or absent saves into the personal workspace, as before; an organization id saves into that workspace's General Type (or a Type of the same shape for content with fields), for a member who can add content there. A non-member's id answers 404, a viewer's 403. 409 already_has_access applies to a member only when the chosen workspace is the capsule's own, and carries actions naming the personal copy the saver can make instead.

Discover

GET /api/preview/public/discover (listDiscover) searches, filters and sorts what owners made Public, the same catalogue the Discover page lists. It needs no credentials and returns only Public content: never link-only, team or self content.

Query parameters, all optional. Anything else, or a value out of range, is a 400 naming the parameter:

  • q: search text, up to 200 characters, in web-search syntax ("exact phrase", -exclude). With q, the best matches come first.
  • category: a category slug. A facet group's slug matches every category under it.
  • tag: a tag slug.
  • sort: new (the default; first publication, newest first) or popular (likes plus saves, then views, then first publication).
  • page: 1 to 500, 24 entries a page.

The response is { entries, total, page, page_size, total_pages, query, facets }. Each entry carries kind (type, collection or capsule), id, title, description, url (its public page), creator (username, name: the public name or null, and profile_url), published_at (the first time it was made public, which a private and public toggle never moves), member_count (null for a capsule), counts (likes, saves, views), categories and tags. facets lists every category (slug, name, group) and the 30 tags most used on public content (slug, name, count).

The route is limited to 60 requests a minute: per account for a signed-in caller (a session, an API key or an assistant), per IP address otherwise.

Rate limits

Authenticated requests to /api/preview/* are rate-limited per user and per API key. The per-user limit is your account's total budget across every tool that uses your account; the per-API-key limit is a sub-ceiling that prevents one integration from consuming the whole budget.

Limits today (subject to change, tracked on the changelog as cosmetic):

BucketDefault
Per-user (Free)250 requests per hour
Per-user (Pro)500 requests per hour
Per-user (Teams)500 requests per hour
Per-API-key (Free)200 requests per hour, tunable per key
Per-API-key (Pro)400 requests per hour, tunable per key
Per-API-key (Teams)400 requests per hour, tunable per key

The per-API-key default is sized to 80% of your per-user cap so a single integration can saturate most of your budget while still leaving headroom for additional keys, and so a plan upgrade lifts the per-key default automatically without you having to edit each key. Override it per key in Settings → API Keys if you need a tighter or looser limit.

Every authenticated response carries the budget headers so you can self-throttle without waiting for a 429:

HeaderMeaning
X-RateLimit-LimitCap for the more restrictive of (per-key, per-user).
X-RateLimit-RemainingApproximate budget left in the current window.
X-RateLimit-ResetUnix-second timestamp when the current window resets.

When a limit is exceeded the response is HTTP 429 with the standard Problem Details body. The headers describe the limit that refused the request, not your overall budget: a share-link 429 reports the share-link limit, a per-key 429 reports the key's limit.

HeaderNotes
Retry-AfterSeconds until the next request will succeed.
X-RateLimit-LimitCap of the policy that refused the request.
X-RateLimit-RemainingAlways 0 on a 429.
X-RateLimit-ResetUnix-second timestamp when that policy's window resets.
X-RateLimit-Scopeglobal (whole-account budget) or endpoint (per-key or per-route ceiling).
X-RateLimit-PolicyName of the policy that refused, for example api_default, api_key, share_create or invite_send. The same names appear in rate-limit-status.

The body carries the same facts for clients that read JSON:

FieldMeaning
detailA sentence naming the limit in words, for example "You've reached the share link limit (30 per hour)."
coderate_limited, or a domain code such as save_rate_limited.
policySame value as X-RateLimit-Policy.
scopeSame value as X-RateLimit-Scope.
retry_after_secondsSame value as Retry-After.
reset_atISO 8601 time when the window resets.
window_secondsLength of the policy's window in seconds (3600 for an hourly limit, 86400 for a daily one).

Per-route policies carry their own Retry-After and limit. The ones you are most likely to meet:

PolicyRouteLimit
share_createPOST /api/preview/share, and PUT /api/preview/share/visibility to unlisted (or its link alias), each only when it makes a new link: handing back an item's existing reusable link spends nothing30 per hour
grant_createPOST /api/preview/grants30 per hour
capsule_save_from_shareSaving a capsule from a share link20 per hour
invite_sendPOST /api/preview/organizations/{id}/invitations10 per hour
capsule_createPOST /api/preview/capsules and log appends100 per hour

One Type or Collection share link shares all its capsules, so sharing a library takes one share_create slot, not one per capsule.

Share links and invitations differ in two ways. A refused request is not counted: one answered 400, 403, 404, 409 or 422 does not use the limit; links that are created and invitations that are sent do. And both limits count a sliding hour, so the reset time on a share_create or invite_send 429 is an upper bound: Retry-After may overstate the wait, never understate it.

Sign-in, sign-up, password, email-verification and MFA routes are the exception. Their 429s do not state a reset time, because a precise time would help someone guessing credentials schedule the next attempt.

Treat Remaining as advisory, not authoritative. Under concurrent load, two requests racing the boundary can each see a non-zero remaining and one of them still gets the 429.

To inspect your current usage and per-API-key counters live, call GET /api/preview/user/rate-limit-status.

Stability & promotion

The stability contract (breaking-change notice periods, promotion criteria for preview → v1, and the channels Symbol uses to announce changes) lives in the dedicated API stability policy. The short version: anything under /api/preview/* may change with at least 14 days notice via the API changelog and an email to API key holders; once /api/v1/* ships, breaking changes there carry a 12-month deprecation window. The notice window is counted from the date each entry is published on the production changelog, which the changelog records automatically.

On this page