# 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.

```bash
curl https://api.console.buildbase.app/api/collections \
  -H "Authorization: 665f1a2b3c4d5e6f7a8b9c0d:your-token-secret"
```

> **Note:**
  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:

```bash
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.

> **Note:**
  **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.



> **Note:**
  **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:

```text
<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:

```bash
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.

> **Note:**
  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:

```json
{
  "_id": "665f1a2b3c4d5e6f7a8b9c0d",
  "name": "Launch announcement",
  "createdAt": "2026-08-15T09:12:44.108Z"
}
```

Update-style endpoints are the exception and return a fixed acknowledgement:

```json
{ "success": true, "message": "updated" }
```

Errors carry an `error` flag, a message, and sometimes the offending field path:

```json
{ "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`/`409`s               |
| `{ 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.

> **Note:**
  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.

```bash
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:

```json
{
  "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:

```typescript
// 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`.

> **Note:**
  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.

> **Note:**
  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](/permissions/overview).

## 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](/workflows/overview) — trigger and monitor automation over this API.
- [Forms](/forms/overview) — the one module with public endpoints that need no token.
- [Server SDK](/server-sdk/overview) — typed helpers for the modules that have them.
