Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.getaptly.com/llms.txt

Use this file to discover all available pages before exploring further.

What this is

Delegate tokens are short-lived JWTs (5-minute expiry) that let an embedded app, iframe plugin, or autonomous agent call the Aptly API as a specific logged-in user — without exposing a raw API key or user password. Your server exchanges the user’s session token for a delegate token. The embedded app or agent gets that delegate token and uses it directly. Tokens are short-lived (~5 minutes) and the parent never caches them — re-requesting is always safe, so the recommended pattern is to request on load and refresh on any 401 response.

The flow

Your server                         Aptly API
──────────────────────────────────────────────────────────────
 1. User is logged in to Aptly.
    Their session JWT is available server-side.

    POST /api/platform/user-token   ───────────────────────►
    access_token: <user-session-jwt>
    body: { readScopes, writeScopes }
                                    ◄───────────────────────
                                    { token, expiration }

 2. Deliver token to embedded app / agent (postMessage,
    response body, env var, etc).

 3. Embedded app / agent uses the token:

    A. Verify identity (no API key needed):
       POST /api/app/verify ─────────────────────────────►
       body: { token }
                             ◄────────────────────────────
                             { userId, email, firstName,
                               lastName, companyId,
                               appClientId, appTitle }

    B. Call the API directly:
       GET /api/board/:boardId ───────────────────────────►
       Authorization: DelegateToken <token>
                                   ◄─────────────────────
                                   board data

Endpoints

MethodPathAuth requiredPurpose
POST/api/platform/user-tokenaccess_token header (user JWT)Issue a delegate token
POST/api/app/verifynoneVerify token, get user identity (requires appClientId in token)
POST/api/board/verify-userx-token (board API key)Verify token, get user identity (any token)
any/api/board/:id, /api/contacts/*, /api/knowledge/*Authorization: DelegateToken <token>Direct scoped API access

Step 1 — Issue a delegate token

POST /api/platform/user-token Call this from your server. Requires the logged-in user’s session JWT in the access_token header. Option A — explicit scopes (no marketplace app registration needed):
curl -X POST https://api.getaptly.com/api/platform/user-token \
  -H "access_token: <user-session-jwt>" \
  -H "Content-Type: application/json" \
  -d '{
    "readScopes": ["boards:*", "contacts:*"],
    "writeScopes": ["boards:*"]
  }'
Option B — registered marketplace app (scopes come from the marketplace item):
curl -X POST https://api.getaptly.com/api/platform/user-token \
  -H "access_token: <user-session-jwt>" \
  -H "Content-Type: application/json" \
  -d '{ "appClientId": "your-app-client-id" }'
Response:
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expiration": "2025-01-01T12:05:00.000Z"
}
StatusMeaning
200Token issued
401access_token header missing, expired, or invalid
401User not found or not associated with a company
400Invalid scope format (see Scopes below)
401appClientId not found for this company

Step 2 — Verify the token (get user identity)

Use this to confirm who the token belongs to. Two options depending on what you have available.

Option A — keyless verify

POST /api/app/verify No API key needed. Only works if the token was issued with an appClientId (option B above).
curl -X POST https://api.getaptly.com/api/app/verify \
  -H "Content-Type: application/json" \
  -d '{ "token": "<delegate-token>" }'
Response:
{
  "userId": "abc123",
  "email": "[email protected]",
  "firstName": "Jane",
  "lastName": "Smith",
  "companyId": "company456",
  "appClientId": "your-app-client-id",
  "appTitle": "My Plugin"
}

Option B — verify with board API key

POST /api/board/verify-user Works for any delegate token. Requires a board API key from the same company.
curl -X POST https://api.getaptly.com/api/board/verify-user \
  -H "x-token: <board-api-key>" \
  -H "Content-Type: application/json" \
  -d '{ "token": "<delegate-token>" }'
Response:
{
  "userId": "abc123",
  "email": "[email protected]",
  "firstName": "Jane",
  "lastName": "Smith",
  "companyId": "company456"
}
StatusMeaning
200Token valid, identity returned
400token field missing from body
401Token expired, signature invalid, or issued for a different company

Step 3 — Call the API directly

Pass the delegate token as Authorization: DelegateToken <token> on any supported route. The token’s scopes must cover the resource you’re accessing.
# Read a board
curl https://api.getaptly.com/api/board/<boardId> \
  -H "Authorization: DelegateToken <delegate-token>"

# Create a card
curl -X POST https://api.getaptly.com/api/board/<boardId>/card \
  -H "Authorization: DelegateToken <delegate-token>" \
  -H "Content-Type: application/json" \
  -d '{ "name": "New card" }'

# Look up a contact
curl -X POST https://api.getaptly.com/api/contacts/by-email \
  -H "Authorization: DelegateToken <delegate-token>" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]" }'

Scopes

Scopes follow the format resource:qualifier.
ScopeGrants access to
boards:*All board routes for the user’s company
boards:<boardId>A single specific board
contacts:*Contact lookup and verification routes
knowledge:*Knowledge base routes
Pass scopes as arrays at issue time:
{
  "readScopes": ["boards:*", "contacts:*"],
  "writeScopes": ["boards:abc123"]
}
readScopes — GET requests and read operations. writeScopes — POST/PUT/DELETE requests and write operations. A missing scope returns 403 Forbidden. No default grants — omitted scopes are denied.

Marketplace vs explicit scopes

There are two ways to assign scopes to a token:
  1. Explicit (readScopes / writeScopes in the request body) — scopes are embedded in the JWT at issue time. Good for one-off scripts and agentic workflows.
  2. Marketplace (appClientId) — scopes are stored on the registered marketplace item and looked up at verify time. Good for registered plugins where you want to change granted scopes without redeploying.

Complete example — server-side token exchange

A minimal Node.js server that exchanges a user session for a delegate token, and client-side code that uses it.
// server.js
import express from "express";

const app = express();
app.use(express.json());

const APTLY_API = "https://api.getaptly.com";

// POST /delegate-token
// Body: { accessToken: "<user-session-jwt>" }
// Returns: { delegateToken, expiration }
app.post("/delegate-token", async (req, res) => {
  const { accessToken } = req.body;
  if (!accessToken) return res.status(400).json({ error: "accessToken required" });

  const response = await fetch(`${APTLY_API}/api/platform/user-token`, {
    method: "POST",
    headers: {
      "access_token": accessToken,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      readScopes: ["boards:*", "contacts:*"],
      writeScopes: ["boards:*"],
    }),
  });

  if (!response.ok) {
    return res.status(401).json({ error: "Could not issue delegate token" });
  }

  const { token, expiration } = await response.json();
  res.json({ delegateToken: token, expiration });
});

app.listen(3000);
// client.js (embedded plugin / agent)
const APTLY_API = "https://api.getaptly.com";

// 1. Get a delegate token from your server
const { delegateToken } = await fetch("/delegate-token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ accessToken: userSessionJwt }),
}).then((r) => r.json());

// 2. Verify who the token belongs to
const identity = await fetch(`${APTLY_API}/api/app/verify`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ token: delegateToken }),
}).then((r) => r.json());

console.log(identity.email); // => "[email protected]"

// 3. Call the API on their behalf
const board = await fetch(`${APTLY_API}/api/board/${boardId}`, {
  headers: { Authorization: `DelegateToken ${delegateToken}` },
}).then((r) => r.json());

Iframe embed pattern

If Aptly loads your app inside an iframe, the parent frame delivers the delegate token via postMessage — no server call needed from your side.

Token lifecycle

  • The parent window does not cache the token. Every aptly-token-request triggers a fresh server-side exchange and returns a new token.
  • Re-requesting is always safe and cheap. The recommended pattern: request on load, then request again on any 401 response. Never cache the token yourself across requests.
  • If sendDelegateToken is false on the embed config, the parent will never respond to a token request.

Receiving and refreshing the token

Use a promise-based helper so the listener is always registered before the request fires, and so 401 retries are straightforward:
const APTLY_API = "https://api.getaptly.com";
let aptlyToken = null;

function requestToken() {
  return new Promise((resolve) => {
    function handler(e) {
      if (e.data?.type === "aptly-delegate-token") {
        window.removeEventListener("message", handler);
        aptlyToken = e.data.token;
        resolve(aptlyToken);
      }
    }
    window.addEventListener("message", handler);
    window.parent.postMessage({ type: "aptly-token-request" }, "*");
  });
}

// Request on load, then start your app
requestToken().then(init);

async function apiFetch(path, options = {}, retry = true) {
  const res = await fetch(`${APTLY_API}${path}`, {
    ...options,
    headers: {
      Authorization: `DelegateToken ${aptlyToken}`,
      ...options.headers,
    },
  });
  if (res.status === 401 && retry) {
    await requestToken();
    return apiFetch(path, options, false);
  }
  if (!res.ok) throw new Error(`API ${res.status}`);
  return res.json();
}
Always use DelegateToken as the Authorization scheme — not Bearer.

Why the one-shot listener?

The parent may respond synchronously (same tick). Registering the listener inside requestToken() — before calling postMessage — guarantees the response is never missed regardless of timing. The listener removes itself after the first matching message so there is no leak.

Why retry once, not loop?

A second consecutive 401 after a fresh token means the user’s session has actually expired or the scope is wrong — retrying further would loop indefinitely. One retry is enough to distinguish “stale token” from “real auth failure.”

For AI agents

When Aptly embeds a generated app or AI agent into an iframe context, the system prompt will include a block declaring what scopes are available:
A delegate token is available in this embedding context. Granted scopes:
- `contacts:*` read
- `knowledge:*` read
- `boards:*` read
If you see this block, use requestToken() from the iframe embed pattern above to receive the token, then use apiFetch for all API calls. The scope list is authoritative — calling a route outside those scopes returns 403 Forbidden. Check whether a scope appears under read or write access before attempting mutations.

Error reference

StatusCodeMeaning
400Required field missing from request body
400INVALID_DATAScope has invalid format or unknown namespace
401INVALID_ACCESS_TOKENToken expired, signature mismatch, or wrong company
401UNAUTHORIZEDUser or app not found, or company mismatch
403FORBIDDENToken valid but lacks required scope for this resource