Admin API
Authenticate with an org API token and call the REST API behind the console modules.
Modules that have no SDK surface — email campaigns, workflows, collections, content, short links, assets — are driven from the console and from a token-authenticated REST API. This page covers the token format, the request shape, the response shape, and the list-query parameters shared by every collection endpoint.
Roughly 120+ endpoints sit behind this API. The count is maintained by hand, so treat it as approximate rather than exact.
curl https://api.console.buildbase.app/api/collections \
-H "Authorization: 665f1a2b3c4d5e6f7a8b9c0d:your-token-secret"Before you start
Create an API token in the BuildBase console under Settings → API tokens,
or with POST /api/tokens.
Managing tokens
| Method | Path | Notes |
|---|---|---|
GET | /api/tokens | List tokens. Returns tokenPreview, never the secret |
POST | /api/tokens | Create. Body takes name (required), description, expiresAt and role. The only response containing the full token |
PATCH | /api/tokens/:id | Update name, description, active, archived. Not role - see below |
DELETE | /api/tokens/:id | Delete |
Creating a token emits token.created, and updating one - including
deactivating it - emits token.updated. Both are workflow triggers and webhook
events, so you can alert on a key being minted or switched off.
expiresAt is optional and validated on create: a value that is not a valid
date, or not in the future, is rejected with
400 expiresAt must be a valid future date. Anything outside the fields above
is rejected too. Set an expiry where you can - it is re-checked on the cached
path as well as against the database, so an expired token stops working the
moment it expires.
Giving a token a role
role names the organization role the token authorizes as. It is the way to
give a token less access than the person creating it:
curl -X POST https://api.console.buildbase.app/api/tokens \
-H "Authorization: $BUILDBASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"changelog-bot","role":"content-editor"}'Three rules on create. The role must already exist in your organization, so a
typo is rejected rather than quietly creating authority. It must be an API
role - roles come in two kinds and only the API kind can be given to a token
(400 Unknown API role: ...); the three built-ins, owner, admin and user,
belong to both kinds and are always accepted. And it may not exceed what you
hold yourself - a member cannot mint a token more powerful than their own
account (403, naming the permissions that exceed).
Create API roles under Settings -> API Roles in the console, or over the API
with POST /api/access-control/save-role and "kind": "api". They are a
separate pool from the roles people hold, which is why a role designed to cap an
integration never turns up in the dialog that changes an employee's role, and a
role designed as a job title cannot be handed to a machine. A role's kind is
fixed when it is created: saving over an existing role with the other kind is
refused (409) rather than moving every principal already carrying it.
GET /api/access-control/roles?kind=api lists what a token may be given;
?kind=user lists what a member may be given; no kind lists both.
A token's role is fixed once it is issued. PATCH will not change it, and
that is deliberate: re-pointing a live credential at a different role is a
privilege change on something already deployed and in use, and it would take
effect at a moment nobody chose. There are two supported ways to change what a
token can do - edit the permissions of the role it already carries, which
applies immediately and to every token carrying it, or issue a new token on the
role you want and deactivate the old one.
Omitting role is not a narrow token, it is a full one. The token falls
back to its creator's role, so one created without a role by an admin is an
admin credential. Name a role whenever you can.
The role is resolved on every request rather than frozen into the token, which has a useful consequence: editing the role changes what the token can do, with no need to re-issue it. Grant the role another resource in the API Roles console and every token carrying it can reach that resource immediately; take one away and they lose it. Deleting the role, or the token, revokes it.
Warning
The role governs organization permissions, not workspace ones. Two checks still resolve from the person who created the token: workspace permissions read that person's workspace membership and role, and resource ownership treats the token as owning whatever they own.
So a token issued as viewer by an admin is a viewer across the organization
and still an admin inside the workspaces that person belongs to. If a token
needs to be narrow inside a workspace too, create it from an account whose
workspace membership is already limited to what it should reach.
Warning
The full secret is returned once. POST /api/tokens is the only response
that carries it. GET /api/tokens omits token entirely and returns a masked
tokenPreview instead, so a listing cannot be used to recover a key.
Store the value when you create it. If it is lost, delete the token and create a new one — there is no way to read it back.
Token format
A token is two parts joined by a colon:
<orgId>:<secret>The orgId is the MongoDB ObjectId of your organization and the secret is a
60-character random string drawn from a 60-character alphanumeric alphabet.
Validation splits on the first colon only, so a secret containing a colon
still parses. The orgId half must be a valid ObjectId or the request is
rejected before any database lookup.
| Failure | Message |
|---|---|
| No colon in the token | Invalid token format. Expected orgId:secret. |
orgId is not an ObjectId | Invalid token format. |
Token unknown or active: false | Invalid or inactive API token. |
| Token's creator user was deleted | Token creator user not found. |
Validated tokens are cached in Redis for five minutes, as a read-through cache.
Every lifecycle change that revokes a token - deactivating, archiving, deleting,
or an expiresAt in the past - takes effect at once rather than waiting out
that window.
Authenticating a request
Send the token in the Authorization header. A Bearer prefix is stripped if
present, so both forms work:
curl https://api.console.buildbase.app/api/links \
-H "Authorization: 665f1a2b3c4d5e6f7a8b9c0d:your-token-secret"
curl https://api.console.buildbase.app/api/links \
-H "Authorization: Bearer 665f1a2b3c4d5e6f7a8b9c0d:your-token-secret"The router decides which credential it is holding by looking for a colon. A
value containing : is treated as an org API token; anything else is validated
as a session JWT. That means a JWT must never contain a colon and an API
token always must.
The auth_token and authorization cookies are also accepted, which is how the
console authenticates. For server-to-server calls, use the header.
Warning
Impersonation tokens are read-only. Any method other than GET, HEAD, or
OPTIONS returns 403 with {"success": false, "message": "Read-only access. Mutations are not allowed in impersonation mode."}. Note this uses a
success field, not the error field used by other errors.
Response shape
There is no response envelope. Successful responses return the resource
directly, not wrapped in a data key:
{
"_id": "665f1a2b3c4d5e6f7a8b9c0d",
"name": "Launch announcement",
"createdAt": "2026-08-15T09:12:44.108Z"
}Update-style endpoints are the exception and return a fixed acknowledgement:
{ "success": true, "message": "updated" }Errors carry an error flag, a message, and sometimes the offending field path:
{ "error": true, "message": "missing field -> name", "path": "name" }Three error shapes, not one
This is the part that trips up client code. Which shape you get depends on which layer rejected the request:
| Shape | Comes from |
|---|---|
{ error: true, message, path? } | The shared validation and error helpers — most 400/404/409s |
{ success: false, message } | Rate limiting, read-only impersonation, and several hand-written route handlers |
Plain text Unauthorized | Every 401 — sent with sendStatus, so there is no JSON body |
Branch on the HTTP status, not the body shape. Reading body.error alone
misses the success: false family, and calling response.json() on a 401
throws.
Status codes
| Code | Meaning |
|---|---|
400 | Missing field, invalid field, empty array, or extra field |
401 | Missing, malformed, or inactive token. Plain-text body |
403 | Action not allowed, account blocked, or read-only impersonation |
404 | Resource not found |
409 | Duplicate — a unique index rejected the write |
429 | Rate limit exceeded |
503 | Server error |
503 rather than 500 is deliberate in the shared ERROR_CODE map. Retry
logic keyed on 500 will miss BuildBase server errors.
Warning
A 200 does not always mean success. Several handlers report failure in the
body while returning 200, because the shared sendResponse helper writes
JSON without setting a status. Workflow publishing is the clearest case — a
validation failure returns 200 with { "success": false }. Check
success on endpoints that return it.
Rate limits
A global limiter covers every /api route: 500 requests per 3 seconds per
IP. Some routes add a stricter limiter on top, keyed per IP per minute:
| Scope | Limit |
|---|---|
Global (all /api) | 500 / 3s |
| Login and register | 20 / min |
| OTP and verification | 10 / min |
| Other auth endpoints | 30 / min |
| Token exchange | 10 / min |
| Dynamic client registration | 5 / min |
| Usage recording | 60 / min |
| Credit consumption | 30 / min |
Limits are counted per IP, not per token, so every token behind one egress address shares a budget.
Exceeding a limit returns 429 with { "success": false, "message": "…" }.
Responses carry the standard RateLimit-* headers; the legacy
X-RateLimit-* headers are disabled, so read the standard ones.
Listing and pagination
Every collection endpoint runs through the same handler, so these query parameters work identically across modules.
| Parameter | Type | Default | Description |
|---|---|---|---|
$page | number | 1 | Page number, 1-indexed |
$limit | number | controller default | Items per page |
filter | object | {} | Mongo-style query, flattened before use |
sort | object | — | Field to direction, e.g. {"createdAt":-1} |
populate | string | '' | Space-separated reference fields to expand |
projection | object | {} | Fields to include or exclude |
pagination | boolean | true | Set false to return every match unpaged |
Note the $ prefix on $page and $limit and its absence on the others.
$page and $limit have no default in the route layer — they fall through to
the pagination plugin's own defaults, so set $limit explicitly rather than
relying on it.
curl -G https://api.console.buildbase.app/api/links \
-H "Authorization: $BUILDBASE_TOKEN" \
--data-urlencode '$page=2' \
--data-urlencode '$limit=25' \
--data-urlencode 'filter={"active":true}' \
--data-urlencode 'sort={"createdAt":-1}'How filter is transformed
filter is not passed to Mongo verbatim. It is flattened to dot-notation and
then re-nested one level deep, which has a practical consequence:
| Filter you send | What Mongo receives | Works? |
|---|---|---|
{"active":true} | {"active":true} | Yes |
{"status":{"$in":["a","b"]}} | {"status":{"$in":[…]}} | Yes |
{"createdAt":{"$gte":"2026-01-01"}} | {"createdAt":{"$gte":…}} | Yes |
{"owner":{"profile":{"city":"X"}}} | {"owner.profile":{"city":"X"}} | No |
Operators survive because they sit one level below their field. Paths two or more levels deep do not round-trip — the last segment is re-nested and the rest becomes a literal dotted key, which Mongo reads as exact subdocument equality rather than a nested path match.
For a deep field, send the dotted path yourself as a flat key:
{"owner.profile.city":"X"}.
Paged response
Responses use the mongoose-paginate-v2 default labels:
{
"docs": [],
"totalDocs": 214,
"limit": 25,
"page": 2,
"totalPages": 9,
"pagingCounter": 26,
"hasPrevPage": true,
"hasNextPage": true,
"prevPage": 1,
"nextPage": 3
}prevPage and nextPage are null at the ends of the range.
With pagination=false the response is a plain array and the envelope fields
are absent. Branch on Array.isArray(response) if you allow both.
A typed client
The API returns bare JSON, so a thin wrapper is usually enough:
// lib/buildbase-admin.ts
const BASE = 'https://api.console.buildbase.app/api';
type ApiError = { error: true; message: string; path?: string };
export async function adminRequest<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: process.env.BUILDBASE_API_TOKEN!,
'Content-Type': 'application/json',
...init.headers,
},
});
if (response.status === 401) {
throw new Error('BuildBase API token is missing, malformed, or inactive.');
}
const body = await response.json();
if (!response.ok) {
const { message, path: field } = body as ApiError;
throw new Error(field ? `${message} (${field})` : message);
}
return body as T;
}Keep the token in an environment variable on the server. It carries the permissions of the user who created it, so it must never reach the browser.
Permissions
A token carries the role it was created with, or its creator's role when it was created without one. Either way, creating a token does not widen access: the role may never exceed what its creator holds, and where a route checks a permission a token lacking it is rejected exactly as a person would be.
Permission coverage is not uniform, though. Authentication and authorization are separate layers here:
| Layer | Applies to |
|---|---|
| Authentication — a valid token is required | Every /api route except the public ones (form submit, redirects, public plan listing, Stripe webhooks) |
| Authorization — a specific permission is required | Routes that opt in with a permission guard, such as email campaigns and collections |
Coverage is not uniform. Some route groups authenticate without checking a permission, so a valid token reaches them whatever role created it.
Where a guard is present, it maps the HTTP method to the action it requires:
| Method | Action required |
|---|---|
GET | read |
POST | create |
PATCH, PUT | update |
DELETE | delete |
Actions are granted per resource, so a role can be given read on a resource
without delete.
Warning
The action comes from the HTTP method, not the intent. Operational
endpoints are POST, so pausing, resuming, retrying and cancelling all
require create — not update. Granting a role update on a resource does
not let it pause anything.
Workflows are split across eight resources
Workflows do not have one permission. Each part is granted separately, so a role can read runs without being able to touch the definitions that produced them:
| Resource | Governs |
|---|---|
workflows | The definition, and its lifecycle — publish, pause, clone, import, emergency stop |
workflows_versions | Published version snapshots |
workflows_instances | Live and historical runs |
workflows_actions | Per-node execution records |
workflows_logs | Per-attempt logs |
workflows_templates | Reusable blueprints |
workflows_trigger_events | The trigger queue, including the dead-letter queue |
workflows_metrics | Execution statistics |
A support role that should see what ran, without being able to change
anything, needs read on workflows_instances and nothing else. read on
workflows would show it the definitions instead — they are separate grants.
A token created by an admin bypasses permission checks entirely — the admin
role is granted everything by design.
Warning
A token created by an admin without a role carries admin access. Where a route has no permission guard, any valid token reaches it whatever role it holds.
So give the token a role narrow enough for the job, and keep it server-side. Creating it from a least-privileged user works too, but it makes the token's authority something you have to look up a user to know.
For the permission model and role definitions, see Permissions.
What a token can reach
Everything the console can, with the same permissions. A token reaches the whole tenant API, organization administration included - settings, outbound webhooks, push campaigns and credentials, Stripe credentials - and the control plane too: members and invitations, installations, and the shared email template library.
A token authorizes exactly as the user who created it. Where a route checks a permission, the token is held to that user's role, custom roles included; where a route checks ownership rather than a role, the token owns what that user owns. So it can revoke an invitation that user sent, and not one somebody else sent.
Two consequences worth planning around:
- A few actions are closed to custom roles by design, for a person and a token alike - claiming ownership of a shared template, resetting a system template. Those require an owner or admin however the permission is granted.
- Control-plane calls resolve your token through the server that owns it. Keys live in your organization's own database, so the control plane asks that server rather than reading it. The result is cached, but if your tenant server is unreachable, control-plane calls made with a token fail closed while a browser session keeps working. It is the right trade for an authentication decision, and worth knowing when you are diagnosing an outage.
Anything reachable from the console but absent from this API is worth reporting rather than working around.
Next Steps
- Workflows — trigger and monitor automation over this API.
- Forms — the one module with public endpoints that need no token.
- Server SDK — typed helpers for the modules that have them.