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 contractVersions 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.tsrewrite 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
wsresolves 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/typesGET /api/preview/capsulesGET /api/preview/searchGET /api/preview/projectsGET /api/preview/sync/snapshot(whereallis 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
404a 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 the404, that a random id gets. - Account routes that act on one fixed workspace refuse a credential whose
limit leaves that workspace out, with
403andreasoncredential_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 the404a 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, or403 Not a member of this organizationon the encryption routes), whatever role the key's owner holds there.GET /api/preview/organizationsandGET /api/preview/user/organization-managementlist 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,reasoncredential_cannot_mint_keys. An organization API key cannot either; it gets403,reasonorganization_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: trueextension (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:
| Member | Type | Notes |
|---|---|---|
type | string | URI identifying the problem type (e.g. https://symbol.chat/problems/not-found). Stable and machine-readable. |
title | string | Short human-readable summary, suitable as a UI fallback. Stable per type. |
status | integer | Mirrors the HTTP status code. |
detail | string | Human-readable explanation specific to this occurrence. |
Endpoints may include extension members at the top level alongside the canonical fields. Common extensions:
| Extension | Where used | Description |
|---|---|---|
errors | 422 from request schema validation | Array 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_field | 422 from rejectUnknownFields | Name of the field that wasn't accepted. |
suggestion | 422 from rejectUnknownFields | Suggested correct field name when one is known. |
capsule_count | 409 when deleting a Type that has capsules | How many capsules block the delete. |
code | 409 from DELETE /api/preview/types/{id} or batch move | Machine-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. |
code | 422 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. |
code | 409 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. |
code | 422 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_aliases | 409 from DELETE /api/preview/types/{id}?migrateTo=<id> when the delete is refused after the migrate committed | What 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. |
code | 409 from DELETE /api/preview/capsules/{id} or POST /api/preview/capsules/batch/delete | citations_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_sample | 409 citations_confirmation_required | How 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. |
impact | 409 strategy_required / impact_confirmation_required | What 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. |
affected | 409 exposure_requires_confirm from batch move and delete-migrate | Array 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_count | 409 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, message | 409 exposure_requires_confirm from the move-to-workspace routes | Who 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). |
actions | 409 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. |
reason | Member-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. |
code | 403 API key scope denial (see below) | insufficient_scope: the API key lacks the scope the action needs. |
code | 409 from POST /api/preview/share or PUT /api/preview/share/visibility | encrypted: 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_id | 400 or 500 from an unexpected failure | Correlation 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
| Status | type URI | title |
|---|---|---|
| 400 | https://symbol.chat/problems/bad-request | Bad Request |
| 401 | https://symbol.chat/problems/unauthorized | Unauthorized |
| 403 | https://symbol.chat/problems/forbidden | Forbidden |
| 404 | https://symbol.chat/problems/not-found | Not Found |
| 409 | https://symbol.chat/problems/conflict | Conflict |
| 422 | https://symbol.chat/problems/unprocessable-entity | Unprocessable Entity |
| 500 | https://symbol.chat/problems/internal-server-error | Internal 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
403https://symbol.chat/problems/forbiddenwith an actionabledetailand a machine-readablereasonextension member (insufficient_role,owner_only, orvisibility_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
404https://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.
Share links and publishing
- The reusable link.
POST /api/preview/sharewith noexpiresAt, nomaxViewsand nonewreturns the resource's one reusable link, minting it only the first time;reusedistruewhen it already existed. Sendnew: true, an expiry or a view limit to mint a separate link. The reply is{ token, share_url, shareToken, reused }: sendshare_url;tokenis the raw value, shown so an integration can store it. - Link rows. Rows from
GET /api/preview/share(andshareToken) carryprimary,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 readsstate: "suspended"withis_live: falsewhile 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|allreturns{ links, next_cursor }: every link you created, newest first, withresource_titleandresource_path.live, the default, lists links that have not expired or run out of views, paused ones included so you can still revoke them;alladds the expired and used-up ones. It never lists anyone else's links. - Revoking many.
POST /api/preview/share/links/revoketakes 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 undernot_found, exactly like one that does not exist. A Public item's listing link is never revoked here: unpublish it withPUT /api/preview/share/visibility. With{ all: true }, listing links never take one of the 500 places, so calling again whilehas_moreis true always reaches the next links;skipped_listingcounts the ones left alone. - Who can see it.
GET /api/preview/share/visibilityaddslive_link_countand, for the resource's owner,publishing:{ public_profile_enabled, has_username, eligible, blocked_reason }. It answers a key with either thereador thesharescope. - Publishing.
PUT /api/preview/share/visibilitywithpublicacceptsenable_public_profile: trueto turn the owner's profile on in the same request. A refused publish names the missing step:
| Status | code | Extra fields | Meaning |
|---|---|---|---|
| 409 | username_required | next_step: "claim_username", claim_path | Claim a username first, in Settings (it needs a browser). |
| 422 | public_profile_required | next_step: "enable_public_profile" | The profile is off. Ask again with enable_public_profile: true. |
| 409 | publish_blocked | blocked_reason | email_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/shareacceptsresourceType: "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 uniform404. -
PUT /api/preview/share/visibilityacceptsprojectforprivateandunlisted;publicanswers422withcode: "not_publishable". -
GET /api/preview/share,GET /api/preview/share/visibilityandGET /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-projectand a capsule update that setsproject_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/capsuleswith aproject_idthe caller shares by link needs thesharescope 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_countis how many capsules a link on the resource publishes (or would publish if the caller shared it now;0for anyone but the owner),exposureis the resource's live state (none,linkorpublic), andpublishes_your_capsulesistruewhen a link is live, the caller owns it, and a capsule the caller creates there would be published: it isfalseunder an organization Type that is Private or link-only, because a capsule created there starts Private. Requiresreadon the resource; anyone else gets the uniform404. -
POST /api/preview/sharefor atype,collectionorproject, andPUT /api/preview/share/visibilitytounlistedorpublicon one, addexposes_capsules: the count the link publishes after the write. -
Collection reads (
GET /api/preview/types/{id}/collectionsand the single read) carryexposure:none,linkorpublic. -
PUT /api/preview/capsules/{id}/permissionsacceptsin_library_share(boolean) alongside or instead ofvisibility, under the sameshareauthorization;falsekeeps the capsule out of every library link. Sent together, both are written at once: a refused request changes neither. TheGET(and the PUT response) returnin_library_shareandshared_since: when the capsule last leftself(ISO 8601), ornull. Version history reads (GET /api/preview/capsules/{id}/versionsand/versions/{n}) show a caller other than the owner only versions changed at or aftershared_since; an earlier version answers404. The permissionsGETalso returnseffective: { 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 readsfalseon anorg_editcapsule.
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 withid,access,email,name,usernameandpending(trueuntil the person first opens Shared with me). Requiresshareon the resource, the same authority as minting a link; anyone else gets the uniform404.POST /api/preview/grantswith{ resourceType, resourceId, email, access }(accessdefaults toview) answers201 { 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 are422with acode:encrypted_not_shareable,not_org_member(organization content goes only to its members),role_ceiling(editfor a member who is a viewer),self_grant, orgrant_limit(50 people per item). Rate limited bygrant_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 gets404.GET /api/preview/shared-with-melists the caller's own grants, newest first:{ items: [{ grant_id, resource_type, resource_id, title, owner_name, access, shared_at, url }], hasMore }. Page withbeforeandbefore_id(theshared_atandgrant_idof 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&limitlists the caller's own likes, newest first, as{ items, next_cursor }. Each item is{ capsule_id, liked_at, state, title, url }, wherestateis the first that holds right now:public(urlis the public address),readable(the caller can read it in Symbol;urlis/capsules/<id>),link(the share link it was liked through still opens it;urlis/liked/<id>, which counts a view of that link when opened) orunavailable(titleandurlarenull, and nothing else about the item is returned). Passnext_cursorback ascursorfor the next page;limitis 1 to 50 (default 20). Requires thereadscope. 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 theupdatescope.
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-workspacewith{ 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 infailed. 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/Nref and alias resolving for any reader who can read it there. Each item insucceedednames the sourceid, thecapsuleIdnow in the team (the copy's for a copy), its new ref and alias, anddroppedCollections. Encrypted capsules answerencrypted_not_movable.POST /api/preview/projects/{id}/move-to-workspacewith{ 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 oftypeMapmaps 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 becomesorg_view. Capsules that can't move stay personal and leave the project, listed inskipped.
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). Withq, 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) orpopular(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):
| Bucket | Default |
|---|---|
| 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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Cap for the more restrictive of (per-key, per-user). |
X-RateLimit-Remaining | Approximate budget left in the current window. |
X-RateLimit-Reset | Unix-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.
| Header | Notes |
|---|---|
Retry-After | Seconds until the next request will succeed. |
X-RateLimit-Limit | Cap of the policy that refused the request. |
X-RateLimit-Remaining | Always 0 on a 429. |
X-RateLimit-Reset | Unix-second timestamp when that policy's window resets. |
X-RateLimit-Scope | global (whole-account budget) or endpoint (per-key or per-route ceiling). |
X-RateLimit-Policy | Name 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:
| Field | Meaning |
|---|---|
detail | A sentence naming the limit in words, for example "You've reached the share link limit (30 per hour)." |
code | rate_limited, or a domain code such as save_rate_limited. |
policy | Same value as X-RateLimit-Policy. |
scope | Same value as X-RateLimit-Scope. |
retry_after_seconds | Same value as Retry-After. |
reset_at | ISO 8601 time when the window resets. |
window_seconds | Length 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:
| Policy | Route | Limit |
|---|---|---|
share_create | POST /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 nothing | 30 per hour |
grant_create | POST /api/preview/grants | 30 per hour |
capsule_save_from_share | Saving a capsule from a share link | 20 per hour |
invite_send | POST /api/preview/organizations/{id}/invitations | 10 per hour |
capsule_create | POST /api/preview/capsules and log appends | 100 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.