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.chatis the OAuth server: discovery, registration, the authorize page, tokens and revocation.https://symbol.chatserves 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-serverIt 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.
A client ID metadata document (recommended for web apps)
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 belocalhost, an IP address, or a name ending in.localor.internal. - The document's
client_idmust equal the URL it is served from, andredirect_urismust list 1 to 10 URIs.client_nameandapplication_typeare optional. Symbol reads no other field. - Serve it as
application/json(or a+jsontype), 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.jsonWith 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_nameis at most 255 characters.redirect_urislists 1 to 10 URIs, each at most 2048 bytes and 8192 bytes in total. See Redirect URIs for which are accepted.application_typeiswebornative.grant_typesmay listauthorization_codeandrefresh_token.scopelists the scopes your app needs, separated by spaces. See Scopes.- A refused registration answers
400witherror: "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
httpredirect tolocalhost,127.0.0.1or[::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. localhostand127.0.0.1are different hosts here. Register the one your dev server uses.- Your production app registers separately, as
web, with itshttpscallback.
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_idstops 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_type | Accepted redirect URIs |
|---|---|
web | https only, and not localhost, 127.0.0.0/8 or ::1. |
native, or left out | https anywhere, or http to localhost, 127.0.0.0/8 or ::1. |
- Custom schemes such as
myapp://callbackare refused. - Symbol stores each URI in its normalised form (for example, a bare host gains
a trailing
/). Theredirect_uriyou send to authorize must match a registered URI exactly, except that anhttpURI onlocalhost,127.0.0.1or[::1]matches on any port. - You may leave
redirect_uriout of the authorize request only when exactly one is registered. - The
redirect_uriyou 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| Parameter | Value |
|---|---|
response_type | code |
client_id | Your client ID: the metadata document URL, or the ID registration returned. |
redirect_uri | One of your registered redirect URIs, URL-encoded. |
code_challenge | The S256 challenge computed above. |
code_challenge_method | S256, the only method Symbol accepts. |
state | Your random value. Check it when the person comes back. |
scope | The scopes you need, separated by spaces. Add type:<slug> to limit your app to one Type. See Scopes. |
resource | Leave it out. The token then works with the REST API. |
What the person sees:
- A Symbol sign-in page, if they are not signed in, followed by their second factor when they have one turned on.
- 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
stateequals the value you stored. If it does not, stop. - If
issis present, check it equalshttps://mcp.symbol.chat/(RFC 9207). - If they deny, the redirect carries
error=access_denied(and yourstate) 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.
scopeis 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
400witherror: "invalid_client"means Symbol does not know yourclient_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
| Scope | What it allows |
|---|---|
read | Read your capsules, types, and search results |
create | Create new capsules and types |
update | Update existing capsules and types |
delete | Delete capsules, types and version history, and restore snapshots |
share | Manage 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
scopesays 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
scopeparameter, the grant is the default above, cut to the registered scopes. - An app registered with no
scopecan be granted any scope it asks for, and so can one whosescopenames none of the scopes above (for example onlyopenid). So can an app that signs in with a client ID metadata document: Symbol does not read ascopefrom 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/typesand"slug": "voice-guides". Your grant is then tied to it, and renaming it keeps your access. - The token response's
scopeincludestype: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, answers403:
{
"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
404a 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
403withreasoncredential_workspace_boundwhen 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/organizationslists 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:
code | What to do |
|---|---|
key_expired | Refresh the access token, then retry. |
key_revoked | The person disconnected your app. Send them through sign-in again. |
unauthenticated or key_not_found | The 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.