Skip to content
Developers
Open app

Focal API

Programmatic access to your Focal organization’s media. Bearer-token authenticated and scoped per organization.

Authentication

Send your token in the Authorization header:

Authorization: Bearer fat_<your token>

Tokens are minted by org admins at app.focal.inc → Settings → Organization → API tokens. The plaintext token is shown exactly once — store it somewhere safe.

Scopes

Each token carries one or more scopes. The minimum scope needed is listed on every endpoint below.

Scope Grants
all:read Every *:read (covers media:read, future boards:read, etc.)
all:write Every scope (read + write across all resources)
media:read List and fetch media, and browse library folders
media:write Update writable fields on media
media:delete Soft-delete media
boards:read List and fetch boards (and their rows and columns)
boards:write Create and update board rows
boards:delete Archive board rows
collections:read List and fetch collections
collections:write Create and update collections
collections:delete Archive collections
comments:read List and fetch comments on media and board rows
comments:write Post, edit and react to comments as the connected user
comments:delete Archive your own comments
spaces:read List and fetch spaces, and a space’s users
spaces:write Change a space’s settings — today the media library’s custom fields (attribute definitions), which reach every media in the space. Distinct from media:write, which writes attribute values; adding options to an existing tags field also rides on media:write, unless the field has allow_create: false, which reserves that to this scope. Does not grant creating or deleting spaces

Space scoping

Tokens can additionally be restricted to a subset of spaces in your organization. When the admin chose specific spaces during creation, the token only sees media (and other resources) in those spaces — anything outside the allow-list returns 404, matching the same not-found shape as cross-org access.

Use GET /v1/account to inspect your token’s allow-list at any time.

space_id is required on list endpoints

Every list endpoint whose resource lives inside a space (GET /v1/media, GET /v1/boards, and more) requires a space_id query param naming exactly one space:

?space_id=<uuid>

There is no implicit default — omitting it returns 400 bad_request. The API reads one space at a time: comma-separated lists and wildcards are not accepted, so every query is explicitly scoped and auditable. To cover several spaces, issue one request per space (GET /v1/spaces lists the spaces your token can see).

Endpoints addressed by a resource id (GET /v1/media/{id}, GET /v1/folders/{id}, …) take no space_id — the resource itself pins the space, which is enforced against your token’s scope either way.

Pagination

List endpoints use cursor pagination:

  1. Call without a cursor to get page 1
  2. The response includes next_cursor (or null if you’re on the last page) and has_more (a boolean for convenience)
  3. Pass next_cursor back as ?cursor=<value> to fetch the next page

Cursors are opaque strings — don’t construct them by hand. They stay stable across writes, so paging through a busy dataset never skips or duplicates rows.

Selecting response fields

Every read endpoint accepts fields — a list of the properties you want back. Everything else is omitted, so you can trim a wide payload down to what you’re actually using:

GET /v1/media?space_id=<uuid>&fields=name,status,html_url

On the POST reads (/v1/media/batch, /v1/media/search) it travels in the JSON body as an array instead: { "fields": ["name", "status"] }.

Three rules worth knowing:

  • id always comes back, whether or not you name it — so a narrowed response is still something you can act on.
  • An unrecognised name is a 400, not a silent no-op. A typo that quietly returned the full object would be worse than an error.
  • Omitting fields returns the complete object, so existing integrations are unaffected.

Each endpoint’s fields parameter documents the exact set it accepts, which is the endpoint’s own response shape.

Board rows: selecting columns

A board row’s cell values arrive as a fields array — one entry per column on the board — which is the bulk of a wide board’s payload. The row endpoints take a second parameter, columns, that narrows that array:

GET /v1/boards/{id}/rows?columns=Status,Owner

Entries are column ids or column names (case-insensitive), and each row’s fields come back in the order you list them — so you know the shape before you parse it. A name that matches no column on the board is a 400. On a view’s rows feed, columns narrows within the view’s visible columns — a column the view hides isn’t selectable through it.

Without columns, fields arrives in the board’s column order, which is the order GET /v1/boards/{id}/columns lists columns in (newest first). A board has no left-to-right order of its own — don’t sort by Column.order, a legacy field nothing maintains (usually null). Presentation order belongs to a view: read visible_column_ids on GET /v1/boards/{id}/views for the order a board is shown in.

The two parameters are orthogonal: fields picks the row’s top-level properties (title, position, fields, …), columns picks what goes inside fields.

Field naming

All request and response field names use snake_case. Date fields are ISO 8601 strings.

media_type vs mime_type

What earlier drafts of this spec called mime_type is now exposed as media_type. The rename tracks the IANA terminology, which retired “MIME type” in favour of “media type” — the value itself (image/jpeg, video/mp4, …) is unchanged. If you integrated against an earlier alpha build, swap the field name in your client; the contents are identical.

Don’t confuse media_type with the top-level type field on Media — type is Focal’s coarse asset bucket (image/video/playable/file), while media_type is the precise IANA identifier ad platforms expect.

Rate limiting

Every authenticated response includes the current quota state:

RateLimit-Limit: 1000          ← total budget per minute
RateLimit-Remaining: 847       ← units left in the current window
RateLimit-Reset: 42            ← seconds until the window resets

Each request charges a cost against the budget. Cheap operations (single fetches, identity lookups) cost 1; list endpoints cost 5; future write endpoints will cost 10. The exact cost is fixed per route — see each operation’s description.

When the budget is exhausted the next request returns 429 Too Many Requests with error.code = "rate_limited" and a Retry-After: <seconds> header. Wait that many seconds before retrying. Quotas are per-token, so minting additional tokens for different workloads is a valid way to scale.

Reporting issues

Every response includes an X-Request-Id header — a UUID that uniquely identifies the request in our logs. Error responses also include it as error.request_id in the body. When reporting a problem to support, include this id and we can look up exactly what happened.

Information

  • OpenAPI version: 3.0.3

Send your API token in the Authorization header: Authorization: Bearer fat_<your token>. Org admins create tokens in Settings → Organization → API tokens.

Security scheme type: http

Bearer format: fat_