Symbol Docs

Sign in with Symbol

Let people sign in to your app with their Symbol account and save to it, using OAuth 2.1 with PKCE.

Your web app can ask a person to sign in with their Symbol account and then act on their behalf: read their capsules, save new ones, or update what is there. The person approves your app on a Symbol consent page, picks which of their workspaces it may reach, and can disconnect it at any time.

Symbol uses OAuth 2.1: the authorization code flow with PKCE. This page takes you from registering your app to saving a capsule, and on to disconnecting.

What you can build

Two hosts are involved:

  • https://mcp.symbol.chat is the OAuth server: discovery, registration, the authorize page, tokens and revocation.
  • https://symbol.chat serves the REST API your app calls with the access token, under /api/preview/.

The OAuth server publishes its metadata (RFC 8414) at:

https://mcp.symbol.chat/.well-known/oauth-authorization-server

It names the issuer https://mcp.symbol.chat/, the authorize, token, registration and revocation endpoints, the one PKCE method (S256), the grant types (authorization_code and refresh_token) and the scopes. symbol.chat itself serves no OAuth metadata.

Call the API from your backend

The REST API sends no CORS headers, on purpose, so a browser will not let a page on your domain read its responses. Make every REST call from your app's server, and keep the tokens there too. The OAuth server's token, registration and revocation endpoints do allow cross-origin requests, but your backend still needs the access token to call the API, so exchange the code there.

Register your app

Before a person can sign in, Symbol needs to know your app's name and where to send the person back. There are two ways to tell it.

Publish a small JSON document on your own domain and use its URL as your client_id. There is no registration call to make or to be rate limited on, and you change your app's name or redirect URIs by editing the document.

Serve this at https://app.example.com/oauth/client.json:

{
  "client_id": "https://app.example.com/oauth/client.json",
  "client_name": "My App",
  "redirect_uris": ["https://app.example.com/oauth/callback"],
  "application_type": "web"
}

The rules:

  • The URL must be https, with a path other than /, no user name or password, and at most 512 characters. It cannot be localhost, an IP address, or a name ending in .local or .internal.
  • The document's client_id must equal the URL it is served from, and redirect_uris must list 1 to 10 URIs. client_name and application_type are optional. Symbol reads no other field.
  • Serve it as application/json (or a +json type), with no redirect, within 5 seconds and at most 64 KiB.
  • Symbol caches a document it fetched for 5 minutes, and a failed fetch for 10, so an edit takes up to 5 minutes to apply.
  • An app registered this way is a public client: it never has a secret.

Dynamic registration

Send your app's details to the registration endpoint (RFC 7591) and keep the client_id it returns:

curl -X POST https://mcp.symbol.chat/register \
  -H "Content-Type: application/json" \
  -d @registration.json

With registration.json holding:

{
  "client_name": "My App",
  "application_type": "web",
  "redirect_uris": ["https://app.example.com/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "scope": "read create",
  "token_endpoint_auth_method": "none"
}

The rules:

  • client_name is at most 255 characters.
  • redirect_uris lists 1 to 10 URIs, each at most 2048 bytes and 8192 bytes in total. See Redirect URIs for which are accepted.
  • application_type is web or native.
  • grant_types may list authorization_code and refresh_token.
  • scope lists the scopes your app needs, separated by spaces. See Scopes.
  • A refused registration answers 400 with error: "invalid_client_metadata" and a description of the field at fault.
  • Registration is rate limited. If it answers 429, wait and try again later, or use a client ID metadata document, which needs no registration.

Always send "token_endpoint_auth_method": "none". PKCE protects the code exchange and your backend protects the tokens, so a public client never sends a secret. If the registration response carries a client_secret, you can discard it. If you leave token_endpoint_auth_method out, the app is registered as a confidential client, which this guide does not cover.

Register the scopes your app needs. Once your registration names any of Symbol's scopes, Symbol never grants your app a scope you did not register, whatever the sign-in link asks for (see Scopes).

While you develop

A web app accepts only https redirect URIs that are not on your own machine, so you cannot sign in to a copy running on your laptop with your production registration. Register a second app for development, as native, which is the only type that accepts an http redirect to your own machine:

{
  "client_name": "My App (development)",
  "application_type": "native",
  "redirect_uris": ["http://127.0.0.1:8765/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "scope": "read create",
  "token_endpoint_auth_method": "none"
}
  • At the authorize step, an http redirect to localhost, 127.0.0.1 or [::1] matches on any port, so a dev server that picks its port each time still works. At the token step you must send the exact URI you sent to authorize, port included.
  • localhost and 127.0.0.1 are different hosts here. Register the one your dev server uses.
  • Your production app registers separately, as web, with its https callback.

Unused apps are removed

Symbol removes an app registered either way after 30 days without use. An app is in use when Symbol issues it a token or a code, or when a signed-in person opens its consent page. An app is never removed while it holds a token that still works, a code not yet exchanged, or a connection the person has not revoked.

  • Dynamic registration: the client_id stops working. Register again and use the new one. A person sent to sign in with the old one sees a Symbol page telling them to go back to your app and connect again.
  • Client ID metadata document: nothing to do. Symbol reads your document again the next time someone signs in.

Redirect URIs

application_typeAccepted redirect URIs
webhttps only, and not localhost, 127.0.0.0/8 or ::1.
native, or left outhttps anywhere, or http to localhost, 127.0.0.0/8 or ::1.
  • Custom schemes such as myapp://callback are refused.
  • Symbol stores each URI in its normalised form (for example, a bare host gains a trailing /). The redirect_uri you send to authorize must match a registered URI exactly, except that an http URI on localhost, 127.0.0.1 or [::1] matches on any port.
  • You may leave redirect_uri out of the authorize request only when exactly one is registered.
  • The redirect_uri you send to the token endpoint must equal the one you sent to authorize, port included. If you left it out at authorize, send the one registered URI.

Send the person to Symbol

Each sign-in starts with a fresh PKCE verifier and state value, kept on your server for this one sign-in:

import { createHash, randomBytes } from "node:crypto";

const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const state = randomBytes(16).toString("base64url");

Then send the person's browser to the authorize endpoint:

https://mcp.symbol.chat/authorize?response_type=code&client_id=<client_id>&redirect_uri=<redirect_uri>&code_challenge=<code_challenge>&code_challenge_method=S256&state=<state>&scope=read%20create
ParameterValue
response_typecode
client_idYour client ID: the metadata document URL, or the ID registration returned.
redirect_uriOne of your registered redirect URIs, URL-encoded.
code_challengeThe S256 challenge computed above.
code_challenge_methodS256, the only method Symbol accepts.
stateYour random value. Check it when the person comes back.
scopeThe scopes you need, separated by spaces. Add type:<slug> to limit your app to one Type. See Scopes.
resourceLeave it out. The token then works with the REST API.

What the person sees:

  1. A Symbol sign-in page, if they are not signed in, followed by their second factor when they have one turned on.
  2. The consent page. It shows your app's name with a note that Symbol has not verified the app, the site it will send them back to, the permissions you asked for and, if they belong to more than one workspace, a workspace picker with every workspace selected.

When they approve, Symbol sends their browser to your redirect URI:

https://app.example.com/oauth/callback?code=<code>&state=<state>&iss=https%3A%2F%2Fmcp.symbol.chat%2F
  • Check state equals the value you stored. If it does not, stop.
  • If iss is present, check it equals https://mcp.symbol.chat/ (RFC 9207).
  • If they deny, the redirect carries error=access_denied (and your state) instead of a code.

Every approval creates a separate connection, which the person sees in their settings.

Exchange the code

From your backend, exchange the code for tokens. The body is form-encoded; the token endpoint accepts no JSON.

curl -X POST https://mcp.symbol.chat/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=<code>" \
  --data-urlencode "redirect_uri=<redirect_uri>" \
  --data-urlencode "client_id=<client_id>" \
  --data-urlencode "code_verifier=<code_verifier>"
{
  "access_token": "<access token>",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "<refresh token>",
  "scope": "read create"
}
  • A code works once, and only for 5 minutes.
  • scope is what the person granted, which can be less than you asked for.
  • The token endpoint takes at most 50 requests every 15 minutes from one address.
  • An answer of 400 with error: "invalid_client" means Symbol does not know your client_id, for example because it removed an unused registration (see Unused apps are removed). Register again and send the person to sign in again.

Keep the person signed in

Access tokens last 3600 seconds by default. Read expires_in rather than assuming an hour. When one expires, refresh it:

curl -X POST https://mcp.symbol.chat/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=<refresh_token>" \
  --data-urlencode "client_id=<client_id>"
  • Every refresh returns a new access token and a new refresh token. Store both, and use the new refresh token next time.
  • A refresh token lasts 30 days from the refresh that issued it, so an app used at least once a month stays signed in.
  • Presenting a refresh token that was already used ends the whole connection and answers invalid_grant. Send the person through sign-in again. Two servers refreshing the same token at once will cause this, so refresh from one place.
  • A refresh never adds scopes.

Scopes

ScopeWhat it allows
readRead your capsules, types, and search results
createCreate new capsules and types
updateUpdate existing capsules and types
deleteDelete capsules, types and version history, and restore snapshots
shareManage sharing and permissions

Ask for the scopes you need, separated by spaces. Scopes Symbol does not know are dropped. If none of the scopes you ask for is known, the person sees an error page on Symbol and nothing comes back to your redirect URI.

With no scope parameter the grant is read create update delete. share is granted only when you ask for it.

An app that registered a scope is granted only scopes it registered:

  • A sign-in that asks for more is narrowed to the registered scopes. The consent page lists only those, and names the ones your app will not get. The token response's scope says what was granted.
  • A sign-in that asks only for scopes you did not register is refused: the person sees an error page on Symbol and nothing comes back to your redirect URI.
  • With no scope parameter, the grant is the default above, cut to the registered scopes.
  • An app registered with no scope can be granted any scope it asks for, and so can one whose scope names none of the scopes above (for example only openid). So can an app that signs in with a client ID metadata document: Symbol does not read a scope from the document.

A call outside the granted scopes answers 403:

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

Limiting your app to one Type

If your app only needs one kind of capsule, ask for that Type alone by adding a type:<slug> token to scope:

scope=read create update type:voice-guides
  • The consent page tells the person your app can use only that Type, naming it when they already have it, or saying your app will create it.
  • The grant covers exactly one workspace, personal unless the person picks another, and never includes share.
  • If the Type does not exist yet, create it with POST /api/preview/types and "slug": "voice-guides". Your grant is then tied to it, and renaming it keeps your access.
  • The token response's scope includes type:voice-guides, after the code exchange and every refresh. If it does not, your request was not honoured.
  • Any call outside the Type is refused. By id it answers 404. Naming another Type, or using an endpoint the limit does not cover, answers 403:
{
  "type": "https://symbol.chat/problems/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "This app's access is limited to the Type \"voice-guides\". It cannot use this endpoint.",
  "reason": "credential_type_bound"
}

See Asking for one Type for the full rules.

Workspaces

A person can belong to their personal workspace and to organizations. On the consent page they choose which of those your app may reach. If they leave every workspace selected, the grant is not limited at all, so it also reaches organizations they join later. If they narrow the choice, it holds for every read and write the token makes:

  • Capsules, Types, Collections and projects outside the chosen workspaces answer the same 404 a nonexistent id gets.
  • A request naming another workspace with ?ws=<id> is refused as if that workspace did not exist (on most routes, 404 Workspace not found).
  • Account routes tied to one workspace answer 403 with reason credential_workspace_bound when that workspace was not chosen.
  • With no ?ws=, a request acts in the personal workspace when it was chosen, otherwise in the one workspace chosen. If several organizations and no personal workspace were chosen, name one with ?ws=.
  • GET /api/preview/organizations lists only the organizations the person chose.
  • If the person has turned off access to an organization for AI assistants, MCP clients and API keys on their own account, calls into it answer 403. They can turn it back on in Settings, MCP Organization Access.

The choice cannot be edited afterwards. To change it, the person disconnects your app in Settings and signs in to it again. See Workspace scoping and Credentials limited to workspaces for the full rules.

Call the API

Send the access token as a bearer token, exactly like an API key, from your backend:

curl -X POST https://symbol.chat/api/preview/capsules \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"title": "Saved from My App", "content_md": "Hello from My App."}'

A 201 returns the new capsule. With no Type named, it lands in the person's default Type. The API reference lists every route and field.

When the token is refused, the API answers 401 with a code:

codeWhat to do
key_expiredRefresh the access token, then retry.
key_revokedThe person disconnected your app. Send them through sign-in again.
unauthenticated or key_not_foundThe token is not one Symbol knows. Send the person through sign-in again.

Disconnecting

The person can disconnect your app at any time in Settings, Connected Apps. That ends the connection and every token issued for it.

Your app can revoke its own tokens, for example when the person signs out of your app (RFC 7009):

curl -X POST https://mcp.symbol.chat/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=<client_id>" \
  --data-urlencode "token=<refresh_token>" \
  --data-urlencode "token_type_hint=refresh_token"

A live token, access or refresh, ends that sign-in: every token issued for it stops working, and its entry leaves the person's Connected Apps. The answer is 200 with {} whether or not anything was revoked. A token already revoked (including one a refresh has since replaced), a token past its expiry, and a token issued to a different client_id revoke nothing. So revoking the refresh token a refresh just replaced, or an access token that expired, is safe housekeeping that keeps the person signed in. An unknown client_id answers 400 with invalid_client.

On this page