Conventions
The central and regional hosts, wire format, pagination, errors, request tracing, idempotency, body limits and versioning — the rules every /v1 route follows.
These rules hold across every /v1 route. Endpoint-specific behavior lives on each
resource's own reference page and guide.
Central and regional hosts
/v1 is served on two kinds of host, and every path belongs to exactly one of them:
| Plane | Host | What it serves |
|---|---|---|
| Central | https://api.brault.app | The key's own context and the brandspace registry: identity, plan, usage, members, roles, the event catalogue. One origin worldwide. |
| Regional | https://us.api.brault.app (https://<region>.api.brault.app) | Everything carrying your content — files, folders, libraries, boards, pages, properties, uploads, downloads, transfers, shared links, webhooks, search. |
The regional host exists for data residency: your brandspace's bytes are answered by a machine in your brandspace's own region, so publishing a content path on the central host would move data across a border the day a second region exists.
The split is per operation, never per path prefix — /v1/files/* is regional and
/v1/me is central, and both live under /v1.
The 7 central paths
There are no others. Everything else under /v1 is regional.
| Path | Method | Why central |
|---|---|---|
/v1/me | GET | The key's own context, and where you look up your hosts. |
/v1/usage | GET | Counters and plan limits; no content. |
/v1/events | GET | The webhook event-type catalogue — a fixed list. |
/v1/brandspace | GET | The brandspace registry row (id, name, username, region). |
/v1/members | GET | Brandspace membership. |
/v1/members/{user_id} | GET | Brandspace membership. |
/v1/roles | GET | Brandspace roles and their permissions. |
GET /v1/health is the single exception: it answers on both hosts. It reads no table,
resolves no key and returns a literal, so it carries no data that could cross a border. It
sets no precedent — any path that touches a row belongs to exactly one plane.
Which host is mine?
Three ways, in order of directness:
- Look at the key.
bsk_us_…meansus.api.brault.app. A key minted before regions existed has no region segment and meansus. - Ask the API.
GET /v1/mereturnshosts.centralandhosts.regionalfor your key, plus the authoritativebrandspace.region. Nothing else has to be derived. - Read the label on the example. Every code block on this site is marked Central or Regional and shows the hostname above it.
The brandspace's region is authoritative; the region printed in the key is advisory
— it tells you which host to dial, it never decides which host may answer. A brandspace
that moves region re-mints no keys: every existing key keeps authenticating, on the new
host, from the first request. GET /v1/me reports the stale hint as key.region_stale.
Calling the wrong host
Two different failures, treated differently, because only one of them is a residency problem:
- Plane mismatch — a regional path called on the central host, or the other way round,
while your brandspace is in the region that host serves. It is served today, and while it
is being phased out it answers with
Deprecation: true, aSunsetdate and aLinkheader pointing back at this page. TheSunsetdate is announced in the changelog before it takes effect. - Residency mismatch — the host's region is not your brandspace's region. Refused with
421 misdirected_requestfrom the day a second region exists, with no grace period: serving it would move your data across a border.details.hostnames the host that will answer anddetails.regionyour brandspace's region, so a client can correct itself without a lookup.
An unrecognized host is never refused — the check fails open, so a health check that
reaches an origin directly is never turned into a 421.
Wire format
- JSON,
snake_caseeverywhere — request bodies and responses alike. - Every object carries
"object": "file" | "folder" | "board" | …naming its type. - Every list looks the same:
{
"object": "list",
"data": [ … ],
"has_more": false,
"next_cursor": null
}- Dates are ISO 8601 UTC (
2026-09-03T10:15:00.000Z). Sizes are bytes as integers; durations are milliseconds. Ids are opaque strings — never parse them for meaning.
Pagination
Every list takes limit (1–100, default 50) and an opaque cursor:
us.api.brault.app— follows the region selector in the top barcurl "https://us.api.brault.app/v1/files?limit=25" \
-H "Authorization: Bearer $BRAULT_API_KEY"
# -> follow with ?cursor=<next_cursor> while has_more is trueThe cursor is opaque by contract, not by cryptography — it is base64url-encoded JSON
that can technically be decoded, but its shape may change without a version bump and
nothing is gained by forging it (the query it indexes is already scoped to your
brandspace). If a cursor was issued for a different set of filters than the request now
carries — you changed limit, added a filter, and so on — the API answers
400 invalid_request rather than silently reinterpreting it: "The cursor was issued for
a different query." The same error covers a cursor that fails to decode at all.
Always page until has_more is false. A few routes (documented on their own pages)
can return fewer than limit items on a page while more remain — never treat a short page
as the end.
Errors
Every failure renders the same envelope:
{
"object": "error",
"status": 404,
"code": "not_found",
"message": "…",
"request_id": "req_…",
"details": {}
}details is omitted when there's nothing structured to add. Codes are stable, lowercase,
and safe to branch on — message is a human sentence that can change.
| Code | HTTP status | Meaning |
|---|---|---|
invalid_request | 400 | Generic malformed request. |
validation_error | 400 | Body or query failed validation. details.errors[{ field, reason }]. |
unauthorized | 401 | No key presented, or it could not be authenticated. |
payment_required | 402 | The brandspace's subscription is unpaid. |
insufficient_scope / forbidden / plan_without_api / plan_feature_unavailable | 403 | A fact about the key or its plan — never about a specific id. |
not_found | 404 | The id does not exist, belongs to another brandspace, or your key's creator lacks permission for it — see below. |
conflict | 409 | The request conflicts with the resource's current state. |
idempotency_conflict | 409 | The same Idempotency-Key was replayed with a different body. |
rate_limited / quota_exceeded | 429 | A bucket or the monthly quota was hit. Retry-After header. |
misdirected_request | 421 | The host you called does not serve this brandspace's region. details.host names the one that does — see Central and regional hosts. |
payload_too_large | 413 | Body over the 1 MB cap. |
unsupported_media_type | 415 | Anything other than application/json. |
internal_error | 500 | Unhandled exception. |
service_unavailable | 503 | The backend cannot serve the request right now. |
See each resource's reference page for the full list of api_key_* and resource-specific
codes; Authentication and keys covers the auth-specific
ones.
The 404 rule
Every entity-scoped route answers 404 not_found for an id it will not serve,
whatever the reason: the id does not exist, it belongs to another brandspace, or your
key's creator simply doesn't have permission for it. All three are byte-identical on the
wire. This is deliberate — the alternative (a 403 that confirms an id exists but you
can't touch it) would let a key discover ids outside its own brandspace. If you're
debugging a permission problem and keep seeing 404, check Settings → Developers —
that's the one place the real reason is visible.
Request id
Every response carries X-Request-Id — send your own (≤ 64 characters) or one is
generated for you. It's stored with the request log entry, so a support conversation can
start from a single header value.
Idempotency
Send Idempotency-Key (≤ 128 characters) on any POST that could duplicate a real-world
effect if retried blindly — creating a library, folder, board, comment, reply, share link
or webhook; adding a file to a board; starting an upload or an import:
us.api.brault.app— follows the region selector in the top barcurl -X POST https://us.api.brault.app/v1/libraries \
-H "Authorization: Bearer $BRAULT_API_KEY" \
-H "Idempotency-Key: 8f14e45f-ceea-4c1a-9c1e-example" \
-H "Content-Type: application/json" \
-d '{"name": "Client Deliverables"}'- A replay of the same key and the same body returns the original response again,
with
Idempotency-Replayed: true. - The same key with a different body answers
409 idempotency_conflict. - A request still in flight under that key answers
409 conflict— retry shortly. - A key over 128 characters is refused outright, before any handler work.
Every POST honors the header, not just the ones listed above — sending it on a
naturally-idempotent action (a move, a copy, publish/unpublish) is harmless.
Body limits
/v1 accepts only Content-Type: application/json on POST/PUT/PATCH —
multipart/form-data included — anything else is 415 unsupported_media_type. File bytes
always go straight to S3 through a presigned URL; see
Upload and organize files. A body over 1 MB is
413 payload_too_large; a request with no declared Content-Length (a chunked body) is
400 invalid_request, checked before the media type.
Versioning
The major version lives in the path (/v1). Additive changes — new fields, new endpoints,
new optional parameters, new enum values documented as extensible — never bump it. A
breaking change ships as /v2, served alongside /v1 for at least 12 months, with
deprecations announced in the changelog and signaled with
Deprecation and Sunset headers on the affected routes.