Brault Developers

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:

PlaneHostWhat it serves
Centralhttps://api.brault.appThe key's own context and the brandspace registry: identity, plan, usage, members, roles, the event catalogue. One origin worldwide.
Regionalhttps://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.

PathMethodWhy central
/v1/meGETThe key's own context, and where you look up your hosts.
/v1/usageGETCounters and plan limits; no content.
/v1/eventsGETThe webhook event-type catalogue — a fixed list.
/v1/brandspaceGETThe brandspace registry row (id, name, username, region).
/v1/membersGETBrandspace membership.
/v1/members/{user_id}GETBrandspace membership.
/v1/rolesGETBrandspace 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:

  1. Look at the key. bsk_us_… means us.api.brault.app. A key minted before regions existed has no region segment and means us.
  2. Ask the API. GET /v1/me returns hosts.central and hosts.regional for your key, plus the authoritative brandspace.region. Nothing else has to be derived.
  3. 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, a Sunset date and a Link header pointing back at this page. The Sunset date 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_request from the day a second region exists, with no grace period: serving it would move your data across a border. details.host names the host that will answer and details.region your 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_case everywhere — 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:

Regionalus.api.brault.appfollows the region selector in the top bar
curl "https://us.api.brault.app/v1/files?limit=25" \
  -H "Authorization: Bearer $BRAULT_API_KEY"
# -> follow with ?cursor=<next_cursor> while has_more is true

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

CodeHTTP statusMeaning
invalid_request400Generic malformed request.
validation_error400Body or query failed validation. details.errors[{ field, reason }].
unauthorized401No key presented, or it could not be authenticated.
payment_required402The brandspace's subscription is unpaid.
insufficient_scope / forbidden / plan_without_api / plan_feature_unavailable403A fact about the key or its plan — never about a specific id.
not_found404The id does not exist, belongs to another brandspace, or your key's creator lacks permission for it — see below.
conflict409The request conflicts with the resource's current state.
idempotency_conflict409The same Idempotency-Key was replayed with a different body.
rate_limited / quota_exceeded429A bucket or the monthly quota was hit. Retry-After header.
misdirected_request421The host you called does not serve this brandspace's region. details.host names the one that does — see Central and regional hosts.
payload_too_large413Body over the 1 MB cap.
unsupported_media_type415Anything other than application/json.
internal_error500Unhandled exception.
service_unavailable503The 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:

Regionalus.api.brault.appfollows the region selector in the top bar
curl -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/PATCHmultipart/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.

On this page