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/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/projects
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
four 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.
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. |
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. |
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"
}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"
}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.
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.
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) | 500 requests per hour |
| Per-user (Pro) | 1000 requests per hour |
| Per-API-key (Free) | 400 requests per hour, tunable per key |
| Per-API-key (Pro) | 800 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 and these additional headers:
| Header | Notes |
|---|---|
Retry-After | Seconds until the next request will succeed. |
X-RateLimit-Scope | global (whole-account budget) or endpoint (per-key or per-route ceiling). |
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.