An addon can call the public REST API on behalf of the user who installed it. The list of available endpoints is documented in the REST section. Authentication is handled via OAuth — the addon receives a short-lived access token and uses it as a Bearer credential when calling the API.
A typical addon has two parts under your control: a frontend (loaded into an iframe via the Web SDK) and an addon backend (your own server). To call the API on behalf of a user, the addon needs an OAuth access token issued for that user.
The first time an addon needs to act on behalf of a user, that user has to grant consent. After that, access tokens can be issued to the addon without further interaction until consent is revoked.
Authorization: Bearer <access_token> when calling the API.api.authorizeOAuth() via the Web SDK. A consent dialog is shown to the user.The Web SDK exposes methods on the API object returned by window.Addon.iframe(). There are two groups: OAuth lifecycle and authenticated HTTP requests.
api.authorizeOAuth(options?) — triggers the consent dialog and returns an access token when the user approves.api.refreshOAuthToken() — exchanges the stored refresh token for a fresh access token.api.getOAuthAccessToken() — returns the current access token if available; useful to check whether the user has already authorized the addon.These methods proxy requests to the public REST API through the host. Before each call the SDK attaches Authorization: Bearer <access_token>; if the cached token has expired, it is refreshed transparently before the request goes out. Only paths under /api/v1/ (and /api/latest/) are reachable — other paths are rejected with 403. A raw fetch from the iframe cannot reach the API directly: the access token lives inside the host, so every call is proxied through it via the SDK.
api.apiRequest(endpoint, options?) — generic request, returns the raw Response.api.apiGet(endpoint) — GET, returns parsed JSON.api.apiPost(endpoint, data) — POST with JSON body.api.apiPatch(endpoint, data) — PATCH with JSON body.api.apiDelete(endpoint) — DELETE.The SDK refreshes the access token automatically when an HTTP method gets a 401. If you need to force a refresh proactively — for example before a long batch of requests, or when handing the token off to another client — call api.refreshOAuthToken() directly.
const api = window.Addon.iframe();
// The SDK auto-refreshes the access token when apiGet/apiPost/... get a 401.
// Call refreshOAuthToken() yourself only when you need a fresh token up front —
// e.g. before a long batch of requests, or to hand a token off to another client.
const { access_token, expires_at } = await api.refreshOAuthToken();
// Inspect the token's lifetime or pass it down a call chain
// (web worker, your own backend, a third-party client, etc.).
console.log('Token valid until', new Date(expires_at));
sendTokenToWorker(access_token);
// Subsequent SDK calls automatically pick up the freshly refreshed token.
const cards = await api.apiGet('/api/v1/cards?limit=50');// Returned by the SDK methods (authorizeOAuth / refreshOAuthToken / getOAuthAccessToken)
interface TokenResponse {
access_token: string;
expires_at: string; // ISO 8601
}
// getOAuthAccessToken() throws if the user has not authorized the addon yet —
// wrap it in try/catch instead of checking a flag.A typical pattern: read the current token, fall back to authorize if missing, refresh on demand.
const api = window.Addon.iframe();
async function ensureAuthorized() {
try {
// Resolves with { access_token, expires_at } if already authorized.
await api.getOAuthAccessToken();
} catch {
// Throws when the user has not authorized the addon yet — start the flow.
await api.authorizeOAuth();
}
}
async function loadCurrentUser() {
await ensureAuthorized();
// api.apiGet attaches Authorization automatically
// and refreshes the access token on 401.
return api.apiGet('/api/v1/users/current');
}Your addon backend can request an access token for any user who has authorized this addon — no iframe required. Authenticate the request with your addon secret as a Bearer token. The secret is shown once after the addon is registered (and on regeneration) in the developer portal — store it in an environment variable on the addon backend, never commit it to a repository, and never expose it to the browser.
Returns the current access token for the given user/company. The response includes has_token: false if the user has not authorized the addon yet.
GET /api/v1/addon-oauth/:addon_uid/tokens/:user_id/:company_id
Authorization: Bearer <addon_secret>
200 OK
{
"has_token": true,
"access_token": "eyJhbGciOiJSUzI1NiIsInR...",
"expires_at": "2026-05-18T14:00:00Z"
}Issues a new access token using the stored refresh token. Call this when you receive a 401 from the API, or proactively before the token expires.
POST /api/v1/addon-oauth/:addon_uid/tokens/:user_id/:company_id/refresh
Authorization: Bearer <addon_secret>
200 OK
{
"has_token": true,
"access_token": "eyJhbGciOiJSUzI1NiIsInR...",
"expires_at": "2026-05-18T14:30:00Z"
}Each company has its own subdomain on the platform (e.g. acme.kaiten.ru), and the OAuth and API endpoints live under that subdomain. If your addon serves more than one company, do not hardcode the full host — keep only the root domain in env (for example PUBLIC_API_BASE_HOST=kaiten.ru) and prepend the company subdomain per request.
The iframe can read the current host from the SDK context: the SDK puts a JSON-encoded payload into window.location.hash, and its targetOrigin field points to the company URL (e.g. https://acme.kaiten.ru). Parse the subdomain from that URL and pass it to the backend — a common pattern is the X-Kaiten-Domain request header. The token endpoint also returns the canonical domain in its response; cache it and reuse for subsequent calls.
In the iframe, read targetOrigin from the location hash, extract the subdomain, and attach it to every call to your addon backend:
// Inside the addon iframe.
// The SDK puts a JSON-encoded payload into window.location.hash; its
// "targetOrigin" field is the company URL (e.g. https://acme.kaiten.ru).
// Cache it once and forward to your backend with every request.
let cachedDomain;
export function getCompanyDomain() {
if (cachedDomain !== undefined) return cachedDomain;
const hash = decodeURIComponent(window.location.hash.replace(/^#/, ''));
if (!hash) return (cachedDomain = '');
try {
const { targetOrigin } = JSON.parse(hash);
if (!targetOrigin) return (cachedDomain = '');
const host = new URL(targetOrigin).hostname;
const baseHost = 'kaiten.ru'; // keep in sync with the backend's PUBLIC_API_BASE_HOST
if (host === baseHost || host === 'localhost') return (cachedDomain = '');
if (host.endsWith(`.${baseHost}`)) {
return (cachedDomain = host.slice(0, -(baseHost.length + 1)));
}
return (cachedDomain = host.split('.')[0]);
} catch {
return (cachedDomain = '');
}
}
// Forward the domain to your own backend with every request.
async function callAddonBackend(path, init = {}) {
return fetch(`/api/addon-backend${path}`, {
...init,
headers: {
...init.headers,
'X-Kaiten-Domain': getCompanyDomain(),
},
});
}A minimal Node.js / Express service that fetches and caches a user token. Note the buildApiUrl(domain) helper — that is where the per-company subdomain is applied:
// services/PublicApiService.js
// BASE_HOST is the root domain only — the company subdomain is appended per request.
// e.g. BASE_HOST = "kaiten.ru" -> built URL = "https://acme.kaiten.ru/api/v1/..."
const BASE_HOST = process.env.PUBLIC_API_BASE_HOST;
const ADDON_UID = process.env.ADDON_UID;
const ADDON_SECRET = process.env.ADDON_SECRET;
function buildApiUrl(domain) {
// For local development BASE_HOST may already include localhost:port —
// in that case skip the subdomain prefix.
if (/^localhost(:|$)|^127\.0\.0\.1(:|$)/.test(BASE_HOST)) {
return `http://${BASE_HOST}`;
}
if (!domain) {
throw new Error('Company domain is required — pass it from the frontend (e.g. X-Kaiten-Domain header).');
}
return `https://${domain}.${BASE_HOST}`;
}
async function fetchUserToken(userId, companyId, domain) {
const url = `${buildApiUrl(domain)}/api/v1/addon-oauth/${ADDON_UID}/tokens/${userId}/${companyId}`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${ADDON_SECRET}` },
});
if (!response.ok) {
throw new Error(`Failed to get token: ${response.status}`);
}
const data = await response.json();
if (!data.has_token) {
throw new Error('User has not authorized this addon');
}
return {
accessToken: data.access_token,
expiresAt: new Date(data.expires_at),
// Echo back the canonical domain — useful if you cache tokens per (user, company).
domain: data.domain,
};
}
async function refreshUserToken(userId, companyId, domain) {
const url = `${buildApiUrl(domain)}/api/v1/addon-oauth/${ADDON_UID}/tokens/${userId}/${companyId}/refresh`;
const response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${ADDON_SECRET}` },
});
if (!response.ok) throw new Error(`Refresh failed: ${response.status}`);
const data = await response.json();
return {
accessToken: data.access_token,
expiresAt: new Date(data.expires_at),
domain: data.domain,
};
}
module.exports = { fetchUserToken, refreshUserToken };The GET token endpoint always returns a valid access token, so the simplest pattern is to fetch one before every API call. If you prefer to keep the token in your own storage, store its expires_at alongside it and refresh proactively (for example with a 5-minute buffer) before reusing.
const tokenCache = new Map(); // key: `${userId}:${companyId}`
// Pass the company domain (see "Frontend: extract domain and forward it").
async function getUserToken(userId, companyId, domain) {
const key = `${userId}:${companyId}`;
const cached = tokenCache.get(key);
const bufferMs = 5 * 60 * 1000; // 5 minutes
if (cached && cached.expiresAt.getTime() - Date.now() > bufferMs) {
return cached.accessToken;
}
// Reuse the canonical domain echoed by the previous response when available.
const fresh = cached
? await refreshUserToken(userId, companyId, cached.domain || domain)
: await fetchUserToken(userId, companyId, domain);
tokenCache.set(key, fresh);
return fresh.accessToken;
}Once the user has authorized the addon, call any /api/v1/ (or /api/latest/) endpoint documented in the REST section. The frontend uses the SDK helpers; the backend attaches the access token to a regular HTTP request.
Inside an iframe, prefer the SDK helpers — they handle the Authorization header, token refresh on 401, and surface errors as thrown rejections.
// Inside an addon iframe
const api = window.Addon.iframe();
// SDK adds Authorization: Bearer <access_token> automatically
// and refreshes the token on expiration.
const user = await api.apiGet('/api/v1/users/current');
// { id, email, full_name, avatar_url, ... }const api = window.Addon.iframe();
const comment = await api.apiPost(
`/api/v1/cards/${cardId}/comments`,
{ text: 'Hello from my addon!' },
);On your own server, attach the access token you fetched (see "Backend: fetching tokens" above) to a regular HTTP request.
// On the addon backend (Node.js)
const response = await fetch(`${API_URL}/api/v1/users/current`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`API error ${response.status}`);
}
const user = await response.json();
// { id, email, full_name, avatar_url, ... }// On the addon backend (Node.js)
const response = await fetch(`${API_URL}/api/v1/cards/${cardId}/comments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: 'Hello from my addon!' }),
});
if (!response.ok) {
throw new Error(`Failed to post comment: ${response.status}`);
}
const comment = await response.json();Common error responses from the OAuth and API endpoints:
| Status | Cause | Action |
|---|---|---|
401 | Access token expired or invalid (or, on the token endpoint, an invalid addon secret). | Refresh the access token and retry once; for the token endpoint, check your addon secret. |
401 after refresh | Refresh token was revoked, or the user lost access. | Prompt the user to re-authorize via api.authorizeOAuth(). |
403 | The addon is not installed in the user's space (no access to their data), or is disabled for the company. | Make sure the addon is added to the user's space — OAuth consent alone does not grant data access. |
404 (token endpoint) | Wrong addon UID / user / company in the URL, or OAuth is not configured for the addon. | Verify the IDs come from the SDK context (not user input) and that the addon has OAuth enabled. |
// buildApiUrl, tokenCache, getUserToken and refreshUserToken come from
// PublicApiService above. Pass the company domain through every call.
async function callApi(userId, companyId, domain, path, options = {}) {
let token = await getUserToken(userId, companyId, domain);
const makeRequest = (t) => fetch(`${buildApiUrl(domain)}${path}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${t}`,
},
});
let response = await makeRequest(token);
if (response.status === 401) {
// Token may have been revoked or rotated mid-flight — refresh once and retry.
const refreshed = await refreshUserToken(userId, companyId, domain);
tokenCache.set(`${userId}:${companyId}`, refreshed);
response = await makeRequest(refreshed.accessToken);
}
return response;
}