API access

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.

Overview

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.

High-level architecture
  • User opens a card and clicks an action contributed by your addon.
  • Addon frontend (iframe) uses the Web SDK to start the OAuth flow, then either calls the API directly or forwards work to its own backend.
  • Addon backend requests an access token via the OAuth API using its addon secret and then calls the API as the user.
  • Server side: the token is validated (JWT, HS256), user permissions are enforced, and the response is returned to the addon.
OAuth authorization flow

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.

Tokens
  • Access token — a short-lived JWT (HS256), valid for about 30 minutes. Passed as Authorization: Bearer <access_token> when calling the API.
  • Refresh token — long-lived, stored server-side. You never see it directly; instead, your backend calls a refresh endpoint and receives a new access token.
Steps
  • The user triggers an action in your addon UI (button, popup, etc.).
  • The addon frontend calls api.authorizeOAuth() via the Web SDK. A consent dialog is shown to the user.
  • After consent, the addon frontend (and the addon backend) can request access tokens for that user.
  • When the access token expires, your backend calls the refresh endpoint to get a new one.
Frontend: SDK methods

The Web SDK exposes methods on the API object returned by window.Addon.iframe(). There are two groups: OAuth lifecycle and authenticated HTTP requests.

OAuth lifecycle
Authenticated HTTP requests

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.

Manual token refresh

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');
Response shape
// 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.
Usage example

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');
}
Backend: fetching tokens

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.

Endpoints
Get access token

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"
}
Refresh access token

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"
}
Multi-tenant URLs (company subdomain)

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.

Frontend: extract domain and forward it

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(),
    },
  });
}
Example: minimal service

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 };
Caching tokens on the backend (optional)

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.

Example: cache with a 5-minute buffer
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;
}
Making API requests

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.

From the frontend (SDK)

Inside an iframe, prefer the SDK helpers — they handle the Authorization header, token refresh on 401, and surface errors as thrown rejections.

Example: GET current user
// 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, ... }
Example: POST a comment to a card
const api = window.Addon.iframe();

const comment = await api.apiPost(
  `/api/v1/cards/${cardId}/comments`,
  { text: 'Hello from my addon!' },
);
From the backend (Node.js)

On your own server, attach the access token you fetched (see "Backend: fetching tokens" above) to a regular HTTP request.

Example: GET current user
// 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, ... }
Example: POST a comment to a card
// 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();
Error handling

Common error responses from the OAuth and API endpoints:

StatusCauseAction
401Access 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 refreshRefresh token was revoked, or the user lost access.Prompt the user to re-authorize via api.authorizeOAuth().
403The 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.
Example: auto-retry on 401
// 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;
}
logo
FlowFast
If you have any questions or need help with integration feel free to write us at support@flowfast.io