Developers
OAuth 2.0 apps
OAuth lets other Tylode users connect your app to their account with only the permissions it needs. They approve it on a consent screen, and they can disconnect it whenever they want without touching anything else.
When to use OAuth
| Use a personal access token when… | Use OAuth when… |
|---|---|
| The code is yours and runs with your own account. | Other people will connect their accounts to your app. |
| You need access to everything, without narrowing scopes. | The app needs a limited, visible set of permissions. |
| It's a script, a cron job or your own AI assistant. | It's a product, an internal tool for your team, or a mobile, desktop or command-line app. |
Never ask other users to paste their personal token into your app: it gives you their whole account. See Authentication for personal tokens.
Register an app
- Open Settings → API and webhooks → API and plugins (go there).
- Under OAuth applications, click Register application.
- Fill in the form:
| Field | Required | Details |
|---|---|---|
| Application name | Yes | Shown on the consent screen: "{name} wants to access your account". |
| What it does | No | A short description shown to the user when asking for permission. |
Homepage URL (the https://your-app.com (optional) field) | No | Shown as an About this app link on the consent screen. |
| Redirect URLs | Yes, at least one | One per line. They're matched exactly. Must use https, except http://localhost and http://127.0.0.1 for development. Can't contain a fragment (#…). |
| Permissions it can request | Yes, at least one | The scopes your app may ask for. Each authorization can request all of them or a subset. |
| Public application | No | Tick it for mobile, single-page (SPA) or command-line apps. The app gets no secret and must use PKCE. |
- Click Register application.
Tylode then shows the app's credentials:
client_id:tylode_app_followed by 24 hexadecimal characters, liketylode_app_3f9a1c2b7d4e5f60a1b2c3d4. It's public and appears in the list of apps.client_secret(only for apps that aren't public):tylode_secret_followed by 43 random characters. Copy it now: it's shown only once.
Registering an app is free on any plan. What needs Pro is using it: each person who authorizes your app must be on Pro or Premium, or its API calls return 403.
Rotate the secret
In the list of apps, click New secret next to the app. A new secret is shown once, and the old one stops working immediately, so update your server first thing. Tokens already issued keep working; only new code exchanges need the new secret. Public apps don't have this button because they have no secret.
Delete an app
Click the trash icon (Delete application) and click again to confirm. This cuts off every user's access at once: all tokens issued to the app stop working.
Apps can't be edited after registering. To change the redirect URLs or the permissions, register a new app and move your users to it.
The authorization code flow
- Your app sends the user's browser to Tylode's authorize URL.
- If they aren't signed in, Tylode asks them to sign in and brings them back.
- They see the consent screen and click Authorize (or Cancel).
- Tylode redirects to your
redirect_uriwith acodeand yourstate. - Your server exchanges the code for an access token at
POST /api/oauth/token. - Your app calls the API with
Authorization: Bearer tylode_oat_….
Step 1: send the user to the authorize URL
GET https://app.tylode.com/oauth/authorize
This is a page the user opens in their browser, not an API call.
| Parameter | Required | Description |
|---|---|---|
client_id | Yes | Your app's client_id. |
redirect_uri | Yes | Where to send the user back. Must match one of the app's redirect URLs exactly, character for character. |
response_type | Yes | Always code. |
scope | Yes | Space-separated list of scopes (at least one). Each must be among the app's registered permissions. |
state | Recommended | A random value you generate and check when the user comes back. Protects against cross-site request forgery. Returned unchanged. |
code_challenge | For public apps | The PKCE challenge. Required for public apps; recommended for all. |
code_challenge_method | With code_challenge | Must be S256. plain isn't supported. |
Confidential app (with a secret):
https://app.tylode.com/oauth/authorize?response_type=code&client_id=tylode_app_3f9a1c2b7d4e5f60a1b2c3d4&redirect_uri=https%3A%2F%2Fyour-app.com%2Fcallback&scope=pages%3Aread%20pages%3Awrite&state=k8Jd2mQx7LpPublic app with PKCE:
https://app.tylode.com/oauth/authorize?response_type=code&client_id=tylode_app_3f9a1c2b7d4e5f60a1b2c3d4&redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback&scope=boards%3Aread%20boards%3Awrite&state=k8Jd2mQx7Lp&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256Remember to URL-encode the values: spaces in scope become %20 (or +), and : becomes %3A.
Step 2: the consent screen
The user sees:
- "{Your app} wants to access your account" and the email they're signed in with.
- Your app's description, if you gave one.
- Under It will be able to, one line per requested scope, like "Read your pages
pages:read". - A note that the app can act on their behalf until they revoke access, and can't see anything they don't have access to.
- "You'll return to your-app.com" (the host of your
redirect_uri) and, if you set a homepage, an About this app link. - Cancel and Authorize buttons.
The user approves all the requested scopes or none, except the delete scopes (…:delete): those appear separately, under It can also delete, each with an unticked checkbox and a note on what can be recovered and what can't. Only the ones they tick are granted, so check the scope in the token response to see which ones you actually have. Ask only for what you need.
Step 3: handle the redirect
If the user clicks Authorize:
https://your-app.com/callback?state=k8Jd2mQx7Lp&code=tylode_code_Vn3pXq8sR2tY6uW1zA4bC7dE0fG5hJ9kL2mN6oP3qR8Check that state matches what you sent, then exchange the code. Codes are valid for 10 minutes and can be used only once.
If something goes wrong, the user is redirected with an error instead:
https://your-app.com/callback?error=access_denied&error_description=...&state=k8Jd2mQx7Lperror | Cause |
|---|---|
access_denied | The user clicked Cancel. |
unsupported_response_type | response_type isn't code. |
invalid_scope | scope is empty, contains an unknown scope, or asks for a scope the app didn't register. |
invalid_request | code_challenge_method isn't S256, or a public app didn't send a code_challenge. |
error_description is a human-readable explanation meant for logs, not for your users.
Some problems can't be sent back safely, so Tylode shows an error page and never redirects: a missing client_id or redirect_uri, an unknown client_id, or a redirect_uri that isn't registered for the app. If users report "Unregistered return URL", check that the URL in your code matches the registered one exactly, including the trailing slash and the port.
Exchange the code for a token
POST https://app.tylode.com/api/oauth/token
Call this from your server (or from the app itself, for public apps). The body can be application/x-www-form-urlencoded or JSON. Client credentials can go in the body or in an HTTP Basic Authorization header.
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Always authorization_code. |
code | Yes | The code from the redirect. |
redirect_uri | Yes | The same redirect_uri used in the authorize step. |
client_id | Yes | In the body or as the Basic auth user name. |
client_secret | Without PKCE | In the body or as the Basic auth password. |
code_verifier | With PKCE | The original verifier. Required whenever you sent a code_challenge. |
Confidential app, secret in the body:
curl -X POST https://app.tylode.com/api/oauth/token \
-d grant_type=authorization_code \
-d code=tylode_code_Vn3pXq8sR2tY6uW1zA4bC7dE0fG5hJ9kL2mN6oP3qR8 \
--data-urlencode redirect_uri=https://your-app.com/callback \
-d client_id=tylode_app_3f9a1c2b7d4e5f60a1b2c3d4 \
-d client_secret=tylode_secret_xxxThe same with HTTP Basic:
curl -X POST https://app.tylode.com/api/oauth/token \
-u "tylode_app_3f9a1c2b7d4e5f60a1b2c3d4:tylode_secret_xxx" \
-d grant_type=authorization_code \
-d code=tylode_code_Vn3pXq8sR2tY6uW1zA4bC7dE0fG5hJ9kL2mN6oP3qR8 \
--data-urlencode redirect_uri=https://your-app.com/callbackPublic app with PKCE:
curl -X POST https://app.tylode.com/api/oauth/token \
-d grant_type=authorization_code \
-d code=tylode_code_Vn3pXq8sR2tY6uW1zA4bC7dE0fG5hJ9kL2mN6oP3qR8 \
--data-urlencode redirect_uri=http://localhost:8765/callback \
-d client_id=tylode_app_3f9a1c2b7d4e5f60a1b2c3d4 \
-d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkIn JavaScript:
const res = await fetch("https://app.tylode.com/api/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
code,
redirect_uri: "https://your-app.com/callback",
client_id: process.env.TYLODE_CLIENT_ID,
client_secret: process.env.TYLODE_CLIENT_SECRET,
}),
});
const token = await res.json();
if (!res.ok) throw new Error(`${token.error}: ${token.error_description}`);Token response
{
"access_token": "tylode_oat_mZ4pQ9sT2vX7yB1cE6gH0jK3nR8uW5aD2fL7oS1tV4x",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "pages:read pages:write"
}scopelists the scopes actually granted, separated by spaces.- The response is sent with
Cache-Control: no-store; don't cache or log it. - Store the access token encrypted, linked to the user.
Token errors
Errors follow the OAuth 2.0 format (RFC 6749, section 5.2), not the { "error": "…" } format of the rest of the API:
{
"error": "invalid_grant",
"error_description": "..."
}error | Status | Cause |
|---|---|---|
invalid_request | 400 | The body can't be read, code or redirect_uri is missing, or code_verifier is missing when a code_challenge was sent. |
invalid_client | 401 | client_id is missing or unknown, the secret is wrong, or a public app didn't use PKCE. |
invalid_grant | 400 | The code is wrong, expired (after 10 minutes), already used, or belongs to another app; the redirect_uri differs from the one in the authorize step; or the code_verifier doesn't match the challenge. |
unsupported_grant_type | 400 | grant_type isn't authorization_code. |
slow_down | 429 | More than 30 requests in a minute from your IP address. |
server_error | 500 | Something failed on Tylode's side. Send the user through the flow again. |
Generate a PKCE verifier and challenge
PKCE protects the code if someone intercepts it. Generate a random code_verifier, send its SHA-256 hash as code_challenge in step 1, and send the verifier itself in the token exchange. The challenge is the base64url encoding of the hash, without = padding.
Node.js:
import crypto from "node:crypto";
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");Browser (Web Crypto):
function base64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
const codeChallenge = base64url(new Uint8Array(digest));Python:
import base64, hashlib, secrets
code_verifier = secrets.token_urlsafe(32)
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
.rstrip(b"=")
.decode()
)Keep the verifier until the user comes back (in the server session, or in memory for a command-line app), and use a new one for every authorization.
Call the API with the token
curl "https://app.tylode.com/api/v1/pages?workspace_id=WORKSPACE_ID" \
-H "Authorization: Bearer tylode_oat_xxx"Start with GET /api/v1/workspaces to find out which workspaces the user is in. It works with any scope, so every authorized app can call it. See List your workspaces.
Each call is made as the user who authorized the app: it sees only what that person can see in Tylode, limited further to the granted scopes. The user must be on Pro or Premium; if they move to Free, calls return 403 until they upgrade again. The usual rate limit of 60 requests per minute applies, shared with everything else the user does through the API.
Scopes
| Scope | What the user sees | Endpoints |
|---|---|---|
pages:read | Read your pages | GET /pages, GET /pages/:id |
pages:write | Create and edit pages | POST /pages, PATCH /pages/:id |
pages:delete | Move pages to the trash | DELETE /pages/:id |
boards:read | Read your boards | GET /boards, GET /boards/:id/lists, GET /boards/:id/cards |
boards:write | Create, change and move cards | POST /boards/:id/cards, PATCH /boards/:id/cards/:cardId |
boards:delete | Delete cards and move boards to the trash | DELETE /boards/:id, DELETE /boards/:id/cards/:cardId |
calendar:read | Read your calendar | GET /calendar/events |
calendar:write | Create and edit events | POST /calendar/events, PATCH /calendar/events/:id |
calendar:delete | Delete events | DELETE /calendar/events/:id |
whiteboards:read | Read your whiteboards | GET /whiteboards, GET /whiteboards/:id/elements |
whiteboards:write | Create and edit whiteboards | POST /whiteboards, POST /whiteboards/:id/elements, PATCH /whiteboards/:id/elements/:elementId |
whiteboards:delete | Delete elements and move whiteboards to the trash | DELETE /whiteboards/:id, DELETE /whiteboards/:id/elements/:elementId |
presentations:read | Read your presentations | GET /presentations, GET /presentations/:id/slides, GET /presentations/:id/slides/:slideId/elements |
presentations:write | Create presentations and slides | POST /presentations, POST /presentations/:id/slides, POST /presentations/:id/slides/:slideId/elements, PATCH /presentations/:id/slides/:slideId/elements/:elementId |
presentations:delete | Delete slides and elements, and move presentations to the trash | DELETE /presentations/:id, DELETE /presentations/:id/slides/:slideId, DELETE /presentations/:id/slides/:slideId/elements/:elementId |
databases:read | Read your databases | GET /databases, GET /databases/:id/rows |
databases:write | Add rows and change cells in your databases | POST /databases/:id/rows, PATCH /databases/:id/rows/:rowId |
databases:delete | Delete rows from your databases | DELETE /databases/:id/rows/:rowId |
chats:read | Read your conversations and their messages | GET /chats, GET /chats/:id/messages |
chats:write | Write messages on your behalf | POST /chats/:id/messages |
mail:read | Read your internal mail | GET /mail, GET /mail/:id |
mail:write | Write and reply to mail on your behalf, and mark it read or archive it | POST /mail, POST /mail/:id, PATCH /mail/:id |
storage:read | Read and download files from My drive | GET /storage, GET /storage/:id |
storage:write | Upload files to My drive | POST /storage |
storage:delete | Move files in My drive to the trash | DELETE /storage/:id |
notifications:read | Read your notifications | GET /notifications |
notifications:write | Mark your notifications as read | POST /notifications |
search:read | Search your entire workspace | GET /search |
GET /workspaces needs no scope. Calling an endpoint without its scope returns 403.
Deleting is a separate scope: :write doesn't include deleting, and any DELETE needs its resource's :delete scope. Chats, mail, notifications and search have no delete scope. What goes to the trash and what's deleted for good is in Deleting: trash or permanent.
New scopes
databases:read, databases:write, storage:write and the seven delete scopes (…:delete) are the newest scopes. A token that has already been issued keeps the scopes it was authorized with: to use a new one, send the user through the flow again with the wider scope. Your app also has to be allowed to request it. Since apps can't be edited, an app registered in Settings can only request the scopes you ticked when you registered it; if you need another one, register a new app. Assistants that connect to the MCP server on their own, such as Claude or ChatGPT, can request every scope in this table.
Not available to OAuth apps
OAuth tokens reach every endpoint in the table, databases, file uploads and deleting included, and the MCP server, where the assistant only sees the tools for the granted scopes. A route that no scope covers returns 403 for OAuth tokens whatever scopes were granted.
Users revoke access
Users see every app they've authorized under Settings → API and webhooks → API and plugins → Applications connected to your account, with the date they connected it and when it was last used. Clicking Revoke and then Confirm cuts off access immediately: the app's tokens stop working on the next request, and you get 401. To reconnect, the user goes through the flow again.
What isn't supported
- Refresh tokens. An access token lasts 90 days from when it's issued; after that, the person authorizes the app again.
- Other grant types, such as client credentials or device code. Only
authorization_codeworks. - OpenID Connect. OAuth discovery is supported (
/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource), but not OpenID Connect discovery. - Token introspection and a revocation endpoint. Users revoke from Settings; to cut everyone off, delete the app.
- Dynamic registration of apps with a secret. Assistants (Claude, ChatGPT…) register themselves as public apps (
/api/oauth/register); an app with a secret is registered in Settings. - The
plainPKCE method. OnlyS256.
Related pages
- Authentication: personal tokens and rate limits.
- Errors and conventions: status codes for API calls.
- Build a plugin: plugins use the same scopes.