BuildBaseBuildBase

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

MethodPathNotes
GET/api/tokensList tokens. Returns tokenPreview, never the secret
POST/api/tokensCreate. Body takes name (required), description, expiresAt and role. The only response containing the full token
PATCH/api/tokens/:idUpdate name, description, active, archived. Not role - see below
DELETE/api/tokens/:idDelete

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.

FailureMessage
No colon in the tokenInvalid token format. Expected orgId:secret.
orgId is not an ObjectIdInvalid token format.
Token unknown or active: falseInvalid or inactive API token.
Token's creator user was deletedToken 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:

ShapeComes 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 UnauthorizedEvery 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

CodeMeaning
400Missing field, invalid field, empty array, or extra field
401Missing, malformed, or inactive token. Plain-text body
403Action not allowed, account blocked, or read-only impersonation
404Resource not found
409Duplicate — a unique index rejected the write
429Rate limit exceeded
503Server 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:

ScopeLimit
Global (all /api)500 / 3s
Login and register20 / min
OTP and verification10 / min
Other auth endpoints30 / min
Token exchange10 / min
Dynamic client registration5 / min
Usage recording60 / min
Credit consumption30 / 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.

ParameterTypeDefaultDescription
$pagenumber1Page number, 1-indexed
$limitnumbercontroller defaultItems per page
filterobject{}Mongo-style query, flattened before use
sortobjectField to direction, e.g. {"createdAt":-1}
populatestring''Space-separated reference fields to expand
projectionobject{}Fields to include or exclude
paginationbooleantrueSet 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 sendWhat Mongo receivesWorks?
{"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:

LayerApplies to
Authentication — a valid token is requiredEvery /api route except the public ones (form submit, redirects, public plan listing, Stripe webhooks)
Authorization — a specific permission is requiredRoutes 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:

MethodAction required
GETread
POSTcreate
PATCH, PUTupdate
DELETEdelete

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:

ResourceGoverns
workflowsThe definition, and its lifecycle — publish, pause, clone, import, emergency stop
workflows_versionsPublished version snapshots
workflows_instancesLive and historical runs
workflows_actionsPer-node execution records
workflows_logsPer-attempt logs
workflows_templatesReusable blueprints
workflows_trigger_eventsThe trigger queue, including the dead-letter queue
workflows_metricsExecution 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.