API design
The Bourd API is the same surface our own dashboard runs on. There is no separate “public” API, and no “internal” API. Anything the dashboard can do, your integration can do, against the same endpoints with the same request and response shapes.
This page is a primer for the team integrating against us. It covers how we think about the surface, why we made the choices we did, and where you will feel the consequences. For per-endpoint details (filterable fields, request schemas, response examples), the OpenAPI reference is the source of truth.
One API, two audiences
Section titled “One API, two audiences”Bourd has no preferred consumer of the API. Our own dashboard isn’t more important to us than your integration: it calls the same endpoints, with the same paths, query parameters, and response shapes. The only difference is authentication: cookies in the browser, Bearer API keys for programmatic clients.
Maintaining a separate internal API and a separate public API is two contracts to evolve, two test surfaces to keep honest, and a slow drift where the internal one accumulates conveniences and the public one atrophies. One contract for both forces the contract to be good enough for both.
The practical consequence for you: if a feature exists in the dashboard, it is reachable via the same documented endpoint. There is no faster, undocumented private path the UI takes that you cannot.
Response shapes
Section titled “Response shapes”Three shapes, used consistently.
Single resources are flat
Section titled “Single resources are flat”A GET on a single resource returns the resource directly at the top level. There is no data wrapper.
{ "object": "prompt", "id": "019abc12-3456-7890-abcd-ef1234567890", "content": "How is Acme perceived among developers?", "status": "active", "created_at": "2024-01-15T09:30:00Z"}Every resource carries an object field naming its type, and an id. The object field is the discriminator regardless of ID format. Resources backed by external systems (Stripe subscriptions, for example) carry the provider’s stable ID, not a UUID.
We rejected a uniform JSON:API-style wrapper because reading a single resource shouldn’t require drilling through data to get to the fields you actually want.
Collections are wrapped
Section titled “Collections are wrapped”Lists return an envelope. The envelope is justified by pagination metadata, which has no obvious home in a flat shape.
{ "object": "list", "data": [{ "object": "prompt", "id": "019abc12-...", "content": "..." }], "has_more": true, "next_cursor": "eyJpZCI6Ii4uLiJ9", "total": 150, "request_id": "req_2xk9f8a3b7c1d4e5"}total is opt-in (see Pagination below). The other fields are always present.
Errors are RFC 9457 Problem Details
Section titled “Errors are RFC 9457 Problem Details”Errors use application/problem+json per RFC 9457.
{ "type": "https://api.geo.bourd.dev/problems/validation_error", "status": 422, "detail": "Prompt content is required", "errors": [ { "field": "content", "message": "must not be empty", "code": "required" } ], "request_id": "req_2xk9f8a3b7c1d4e5"}The type URI is the stable identifier. It will not change. The list of error types is small and machine-readable: validation_error, authentication_required, forbidden, not_found, conflict, rate_limited, and a handful of others. Branch on type, not detail.
We chose RFC 9457 because the documentation burden for the envelope itself is zero, and existing client libraries already understand it.
https://api.geo.bourd.dev/workspaces/{workspace_id_or_slug}/promptsA few conventions:
- No version prefix. No
/v1/, no/v2/. Versioning lives in a header. - Workspace path accepts UUID or slug. Both resolve the same workspace. Server-generated URLs (
Linkheaders, futureContent-Location) always use the UUID form, so cached self-links survive workspace renames. - Plural nouns, kebab-case.
/prompts,/prompt-runs,/brand-mentions. - Explicit parameter names in every template:
{prompt_id},{tag_id}. Never reuse{id}for two different resource types in the same path. - PATCH for partial updates, not PUT. Send only the fields you want to change. PUT semantics (full-resource replacement) are surprising for clients that don’t intend to clear unspecified fields.
- Flat over nested when the child has its own global ID. We nest only for true sub-resources without independent identity.
Pagination
Section titled “Pagination”Cursor-based, always.
GET /workspaces/acme/prompts?limit=20&cursor=eyJpZCI6Ii4uLiJ9Defaults: limit=20, max 100. The list envelope contains has_more, next_cursor, and (opt-in) total. When has_more is true, the response also includes a Link header per RFC 8288:
Link: <https://api.geo.bourd.dev/workspaces/{uuid}/prompts?cursor=...>; rel="next"If you would rather follow the header than parse the cursor, you can. The cursor itself is opaque base64-encoded JSON. Treat it as a blob; do not try to construct or decode it on your side.
Why cursor, not offset. Two reasons. Correctness: under concurrent writes, offset shifts as rows are inserted or deleted between page fetches. Cursor pagination is stable. Performance: OFFSET 10000 reads and discards 10,000 rows. Cursor pagination is constant-time regardless of position in the result set.
Why total is opt-in. A COUNT(*) query is expensive at scale. Most consumers do not need the total. Pass ?include_total=true when you do.
Filtering and sorting
Section titled “Filtering and sorting”Filters are flat query parameters. Equality, with comma-separated lists for multi-value. Range operators use bracket suffixes:
?status=active&tag_id=uuid1,uuid2&created_at[gte]=2024-01-01Multi-value filters use a single comma-separated parameter — ?tag_id=a,b,c, not ?tag_id=a&tag_id=b&tag_id=c. If your client library emits repeated keys by default (browser URLSearchParams.append, axios with array values, Python requests with list params), join the values into one string before sending:
// Build a single comma-separated value:const params = new URLSearchParams({ tag_id: tagIds.join(',') });Each multi-value filter accepts at most 50 values. Sending more returns 422 validation_error. The cap is published as maxItems: 50 on each multi-value parameter in the OpenAPI spec, so generated clients carry the constraint without having to read this page.
Combination semantics
Section titled “Combination semantics”Get either wrong and your filter returns the wrong rows.
- Within a field, values combine with OR.
?tag_id=A,Bmatches rows tagged A or B. - Across fields, filters combine with AND.
?tag_id=A&model=gpt-5.4matches rows tagged A and using modelgpt-5.4.
Each filter constrains one field, and combining filters across fields only narrows the result further. If you need within-field intersection (rare: “tagged with all of A, B, and C”), the endpoint may choose to expose a distinct named parameter for it. We do not silently overload equality with AND semantics.
Search
Section titled “Search”?search=value is always a single-value parameter — one query string, not a multi-value list. The server tokenizes and matches per the endpoint’s definition. Pass ?search=red shoes, not ?search=red,shoes; the latter would be treated as the literal string red,shoes.
Sorting
Section titled “Sorting”?sort=-created_at,statusComma-separated, leading - for descending. Multiple sort fields apply in order. Every list endpoint supports at minimum sort=created_at and sort=-created_at.
Per-resource sortable and filterable fields are listed in the OpenAPI spec. Field names are snake_case throughout, both in URLs and in JSON bodies.
Versioning
Section titled “Versioning”We use a date-based version header, not a URL prefix.
Geo-Version: 2026-02-19The server echoes the applied version on every response. If the header is omitted, the server applies the version stored on your API key (set at key creation) or the current default for cookie auth.
URL versioning couples every breaking change to a URL cut, which forces every consumer to update bookmarks, integrations, and configs in lockstep. Header versioning lets you opt into a version when you are ready, while keeping URLs stable and shareable.
Within a version, the rules are:
- Additive changes are always safe. New fields, new endpoints, new optional parameters.
- Fields are not removed. Deprecated fields keep being returned to consumers pinned to that version.
- Types do not change. A string today is a string forever within its version.
- Semantics do not change. A field’s meaning is fixed.
- Ignore unknown fields on your side. This is a robustness requirement on consumers. We will add fields.
When a breaking change is unavoidable, it lands on a new Geo-Version date. Deprecated versions get a Deprecation header (RFC 9745) and a Sunset header (RFC 8594), with a minimum six-month window between the two. After sunset, the version returns 410 Gone.
Today there is only one version: 2026-02-19. Pin it explicitly. When we cut a new one, you will see the deprecation headers on the responses to your existing calls before anything changes.
Errors and the request ID
Section titled “Errors and the request ID”Every response carries a Request-Id header. List and error responses also include the same value as request_id in the body. When you open a support ticket, quote this ID. It pivots us directly to the trace.
The request ID is the only thing you should rely on for cross-system debugging. We derive it from our OpenTelemetry trace ID for our own observability, but the public contract is just an opaque req_-prefixed string. Do not try to parse it.
Strict validation
Section titled “Strict validation”The API rejects unknown fields. Both directions.
- Unknown fields in a request body return
422 Unprocessable Contentwith error typevalidation_error. A typo’d field (descritionfordescription) would otherwise be silent data loss. - Unknown query parameters return
400 Bad Requestwith error typeunknown_query_parameter. A typo’d filter (?stauts=active) would otherwise silently disable the filter and return the wrong result set.
We would rather make you fix a typo than hand you the wrong answer.
Data conventions
Section titled “Data conventions”A short list of things that hold everywhere:
- Datetimes. RFC 3339, always UTC:
2024-01-15T09:30:00Z. - UUIDs. Lowercase, hyphenated. New resources use UUIDv7, which is time-sortable and friendly to keyset pagination.
- Field names.
snake_caseeverywhere, in URLs and JSON. - Null vs absent. Explicit
nullmeans “this field is supported but has no value.” A field omitted from the response entirely means “this field doesn’t apply to this resource.” Clients parsing the response should branch on which one they got. - Compression.
Accept-Encoding: gzip, bris supported.Vary: Accept-Encodingis on every response.
Adopt, don’t invent
Section titled “Adopt, don’t invent”A pattern you will see repeated: where an IETF RFC or industry convention solves the problem, we use it instead of inventing a Bourd-flavoured version. Each is a spec we don’t have to write, debate, or maintain.
- RFC 9457 for error envelopes.
- RFC 8288 for
Linkheaders (pagination, future relationships). - RFC 9745 for
Deprecationheaders on retired API versions. - RFC 8594 for
Sunsetheaders. - OpenAPI 3.1 for the spec, generated code-first from the running binary so the spec and the implementation cannot drift.
Authentication, briefly
Section titled “Authentication, briefly”Cookies for the browser. Bearer tokens for everything else. Two flavours of API key:
- Account keys (
bourd_ak_) for account-level operations (billing, team, workspace lifecycle). - Workspace keys (
bourd_wk_) for everything inside one workspace (prompts, brands, reports, schedules).
Workspace keys are bound to a single workspace at creation. They are the right default for most integrations. Use account keys only when you actually need account-level reach.
For the full key model, scopes, and forbidden permissions, see the API keys guide. For agent-driven integrations (Claude, ChatGPT, Cursor), use the MCP server instead, which is user-scoped and uses OAuth.
What’s still coming
Section titled “What’s still coming”Some pieces of the contract are designed but not yet implemented:
- HTTP caching.
ETag,If-None-Match, and304 Not Modifiedon single-resource GETs. - Write preconditions.
If-MatchETag checks on PATCH and DELETE for optimistic concurrency. - Rate limiting.
X-RateLimit-*headers and429responses withRetry-After. - Idempotency keys.
Idempotency-Keyheader on POST for safe retries. 406 Not Acceptablewhen anAcceptheader genuinely excludes JSON.
Until these ship, treat their absence as the default behaviour: no conditional GETs, no per-key rate ceilings, no safe retry header. The error types are already reserved in the catalogue so adding them is additive and will not break your version pin.
Where to look next
Section titled “Where to look next”- OpenAPI reference. Every endpoint, every field, every status code. The authoritative per-endpoint truth.
- API keys. Creating, scoping, and rotating keys.
- MCP server. User-scoped agent integration over OAuth, if you are building on Claude, ChatGPT, or Cursor rather than a server.
- Running analysis. What the prompt-run lifecycle actually looks like once you can make calls.