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
| Method | Path | Auth required | Purpose |
|---|
POST | /api/platform/user-token | access_token header (user JWT) | Issue a delegate token |
POST | /api/app/verify | none | Verify token, get user identity (requires appClientId in token) |
POST | /api/board/verify-user | x-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"
}
| Status | Meaning |
|---|
| 200 | Token issued |
| 401 | access_token header missing, expired, or invalid |
| 401 | User not found or not associated with a company |
| 400 | Invalid scope format (see Scopes below) |
| 401 | appClientId 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"
}
| Status | Meaning |
|---|
| 200 | Token valid, identity returned |
| 400 | token field missing from body |
| 401 | Token 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.
| Scope | Grants 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:
- Explicit (
readScopes / writeScopes in the request body) — scopes are embedded in the JWT at issue time. Good for one-off scripts and agentic workflows.
- 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
| Status | Code | Meaning |
|---|
| 400 | — | Required field missing from request body |
| 400 | INVALID_DATA | Scope has invalid format or unknown namespace |
| 401 | INVALID_ACCESS_TOKEN | Token expired, signature mismatch, or wrong company |
| 401 | UNAUTHORIZED | User or app not found, or company mismatch |
| 403 | FORBIDDEN | Token valid but lacks required scope for this resource |