Skip to content

BimZone API v1 — consumer reference

The machine-readable contract is openapi.json, generated from the server's own operation registry and validated against live responses in CI. index.html renders it offline.

This document covers the four things a generated spec cannot tell you: what is guaranteed, what is not, what you must do, and how long v1 lasts.


Authentication

A workspace-scoped API key in the X-Api-Key header. Everything you can reach is tenanted to the workspace that key belongs to; there is no way to widen that.

Scopes form a ladder — read < write < admin. Each operation states its minimum in the spec.

GET /api/v1/whoami

is the first call worth making: it tells you which workspace the key is bound to and what it may do, which is otherwise guesswork against a 401.

Errors

Every failure has one shape:

{ "error": { "code": "not_found", "message": "Project not found", "requestId": "req-4f2" } }
Field Guarantee
code Stable. A closed set. New members may be added; existing ones will not change or be removed within v1. Branch on this.
message Not stable. Written for a human reading a log. Do not parse it, match on it, or show it to end users as the sole explanation.
requestId Quote it when reporting a problem. It is how a deliberately vague 500 becomes diagnosable at our end.

Codes: bad_request · unauthorized · forbidden · not_found · conflict · validation_failed · payload_too_large · rate_limited · internal_error.

A resource in another workspace returns 404, not 403. This is deliberate. A 403 would confirm the id exists, which lets anyone with a key enumerate other tenants' identifiers. You cannot distinguish "does not exist" from "not yours", and neither can an attacker.

400 versus 422: a body that is not valid JSON is 400 bad_request; a body that parses and then violates the schema is 422 validation_failed, with a details array naming the fields. The split tells you whether your serialiser is broken or your payload is wrong.

Pagination

Cursor-based. Pass limit (default 50, max 200) and the nextCursor from the previous page:

GET /api/v1/projects/{projectId}/issues?limit=100
GET /api/v1/projects/{projectId}/issues?limit=100&cursor=MjAyNi0wOC0wMlQx...

nextCursor is null on the last page. Stop on nextCursor === null, not on an empty data — a full final page is followed by a null cursor, and a loop that waits for an empty page will make one extra request or, worse, stop early if you invert the condition.

The cursor is opaque. Do not parse, construct, or persist assumptions about it; its encoding is not part of the contract. A cursor we cannot decode is rejected with 400 rather than silently restarting from the beginning, because a silent restart means you reprocess the entire register believing you resumed.

Why not offset? Because you are probably polling. Under ?offset=, a row inserted between two requests shifts everything down one: the next page repeats a row you have already handled and another slides past the boundary and is never delivered. Cursors seek by a value concurrent writes cannot move.

Webhooks

Create a subscription with POST /api/v1/webhooks (scope: write). The response contains secret once and it is never returned again — if you lose it, delete the subscription and create another.

It is stored encrypted, not hashed: signing your deliveries requires the secret itself, so it has to be recoverable by us. What that buys you is that a copy of the database alone does not yield your signing key — the decryption key is held separately — but it is a weaker guarantee than a one-way hash, and you should treat the secret as a shared credential rather than as something only you hold.

Each delivery carries:

Header Meaning
X-BimZone-Delivery-Id Unique per delivery. This is your idempotency key.
X-BimZone-Event The event type.
X-BimZone-Timestamp Unix milliseconds, and part of the signed material.
X-BimZone-Signature sha256=<hex> — HMAC-SHA256 of `${timestamp}.${rawBody}` under your secret.

Verify over the raw body, before any JSON parsing, and reject a timestamp outside a tolerance you choose (five minutes is typical). The timestamp is signed precisely so that a captured request cannot be replayed against you forever.

Delivery is at-least-once, and you must deduplicate

This is not a caveat in small print; it is a requirement. Networks fail after your server has already committed, so a delivery you have processed can arrive again. Record X-BimZone-Delivery-Id and ignore repeats.

We cannot do this for you, and a system that implies exactly-once has simply moved the problem to you without saying so.

Failures retry six times with growing backoff over roughly a minute, then dead-letter. Five CONSECUTIVE dead-lettered deliveries disable the subscription — consecutive meaning with no successful delivery in between, so an endpoint that recovers on its own is never switched off by failures it already came back from. The reason is recorded and visible as active and disabledReason in GET /api/v1/webhooks.

To recover:

  1. Fix the endpoint.
  2. POST /api/v1/webhooks/{id}/enable — re-enables the subscription and clears the failure count, so the outage that disabled it is not still counted against you. Your signing secret is unchanged, so no code change is needed at your end. Enabling an already-active subscription is a no-op.
  3. Optionally re-queue individual dead-lettered deliveries with POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay. Inspect them first with GET /api/v1/webhooks/{id}/deliveries.

Replay only re-queues one delivery; it does not re-enable a disabled subscription, and a replay against a disabled subscription will simply dead-letter again. Enable first.

Payloads are thin — an event type and identifiers. Re-fetch through the API for the current state. This is on purpose: a fat payload would be a second read path with none of the tenancy checks the API enforces, and it would be stale by the time you read it.

Event types, and exactly when each one fires

Event Fires when
issue.created An issue is created.
issue.updated Any issue mutation — including the one that closes it.
issue.closed An issue enters a terminal state: status becomes closed or verified.
rfi.created An RFI is raised.
clash.decided A clash decision is written to the governance ledger.

Three consequences worth stating rather than leaving you to discover:

A close emits two events. issue.updated fires for it as well as issue.closed, so a consumer that only wants to track "everything that changed" can subscribe to issue.updated alone and not silently miss closures. If you subscribe to both, you will receive two deliveries for one close; they carry different X-BimZone-Delivery-Id values because they are two different events, not a redelivery.

verified counts as closed. An issue can go straight from open to verified — resolved and signed off in one step — and that is an entry into closure, so issue.closed fires. Editing an already-closed issue does not re-fire it; the event marks the transition, not the state.

clash.decided is transactional. It is emitted with the ledger write, so a decision that is rolled back produces no event. You will never be told about a decision that does not exist.

Rate limits

Two budgets apply: a shared per-deployment limit, and a per-key quota so one integration cannot consume the whole allowance. Exceeding either returns 429 rate_limited with Retry-After in seconds. Honour it; retrying immediately makes the situation worse for everyone including you.

Versioning and deprecation

v1 is a compatibility commitment. Within it we will add — new operations, new optional response fields, new error codes — and we will not rename or remove anything, or change the type of an existing field. Treat unknown fields as forward compatibility, not as an error.

When an operation is deprecated it starts sending, per RFC 8594:

Deprecation: Wed, 01 Jan 2025 00:00:00 GMT
Sunset:      Thu, 01 Jan 2026 00:00:00 GMT
Link:        </api/v2/thing>; rel="successor-version"

Support window: at least 12 months from the successor's general availability, and the Sunset header is the authoritative date for any given operation. Log these headers; they are the only warning you will get that does not depend on someone reading an email.

The window is a commercial commitment recorded in docs/roadmap/Phase-4/OPEN_QUESTIONS.md (OQ-4.3) and may be extended, never shortened, for an operation already announced.

What v1 does not do

Stated plainly so you can plan around it rather than discover it:

  • No OAuth2 app authorisation. Integrations use an API key issued by a workspace admin; there is no consent screen yet. It is planned, and adding it will not break key-based clients.
  • No write access to governance decisions. Clash sign-off, RFI answers and approval transitions are enforced by maker-checker and segregation-of-duties rules that assume a human actor in a workspace. Exposing them to machine clients needs its own threat model.
  • No bulk export endpoint. Paginate.
  • No sandbox environment. Point a key at a test workspace.