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:
- Fix the endpoint.
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.- Optionally re-queue individual dead-lettered deliveries with
POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay. Inspect them first withGET /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.