Skip to content

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.

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.

Three shapes, used consistently.

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.

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 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}/prompts

A 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 (Link headers, future Content-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.

Cursor-based, always.

GET /workspaces/acme/prompts?limit=20&cursor=eyJpZCI6Ii4uLiJ9

Defaults: 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.

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-01

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

Get either wrong and your filter returns the wrong rows.

  • Within a field, values combine with OR. ?tag_id=A,B matches rows tagged A or B.
  • Across fields, filters combine with AND. ?tag_id=A&model=gpt-5.4 matches rows tagged A and using model gpt-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=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.

?sort=-created_at,status

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

We use a date-based version header, not a URL prefix.

Geo-Version: 2026-02-19

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

  1. Additive changes are always safe. New fields, new endpoints, new optional parameters.
  2. Fields are not removed. Deprecated fields keep being returned to consumers pinned to that version.
  3. Types do not change. A string today is a string forever within its version.
  4. Semantics do not change. A field’s meaning is fixed.
  5. 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.

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.

The API rejects unknown fields. Both directions.

  • Unknown fields in a request body return 422 Unprocessable Content with error type validation_error. A typo’d field (descrition for description) would otherwise be silent data loss.
  • Unknown query parameters return 400 Bad Request with error type unknown_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.

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_case everywhere, in URLs and JSON.
  • Null vs absent. Explicit null means “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, br is supported. Vary: Accept-Encoding is on every response.

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 Link headers (pagination, future relationships).
  • RFC 9745 for Deprecation headers on retired API versions.
  • RFC 8594 for Sunset headers.
  • OpenAPI 3.1 for the spec, generated code-first from the running binary so the spec and the implementation cannot drift.

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.

Some pieces of the contract are designed but not yet implemented:

  • HTTP caching. ETag, If-None-Match, and 304 Not Modified on single-resource GETs.
  • Write preconditions. If-Match ETag checks on PATCH and DELETE for optimistic concurrency.
  • Rate limiting. X-RateLimit-* headers and 429 responses with Retry-After.
  • Idempotency keys. Idempotency-Key header on POST for safe retries.
  • 406 Not Acceptable when an Accept header 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.

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