Versioning
Every API lives under /api/<feature>/v1/ on https://bryanlabs.net. The path version only changes for a breaking change. Additive changes (new fields, new endpoints, new enum values) ship without a version bump, so parse responses leniently and ignore members you do not know. Each API has an OpenAPI 3.1 document on its docs page; the changelog records every behaviour change.
Authentication
Read endpoints are public. Endpoints that act as you accept either the site session (the browser sends it) or a personal API key sent as Authorization: Bearer blk_…. Keys are created under Account, shown once, stored hashed, and can be rotated or revoked at any moment. A key carries only the scopes it was created with; a missing scope answers 403 insufficient_scope, an unknown or revoked key 401 invalid_api_key with a WWW-Authenticate header.
| Scope | Allows |
|---|---|
| snapshots:read | Read the snapshot catalog and state-sync data. |
| snapshots:download | Mint signed snapshot download URLs. |
| faucet:request | Request testnet tokens as this account. |
| tax:read | Read tax balances, events and price series. |
| upgrades:webhooks | Manage upgrade notification webhooks. |
| wasm:read | Read CosmWasm code and contract data. |
Keys are secrets. Keep them out of URLs, client-side code and shared logs; rotate one the moment it may have leaked.
Request ids
Every API response carries X-Request-Id. Send your own (8 to 128 characters of letters, digits, ., _ or -, for example your trace id) and it is echoed back; otherwise one is minted. The same value appears as request_id in every error body and in our server logs, so quoting it lets us find the exact call.
# Send your own request id and an API key; both come back in the response.
curl -sS https://bryanlabs.net/api/snapshots/v1/chains \
-H 'authorization: Bearer blk_...' \
-H 'x-request-id: build-4821-step-3' \
-D - -o /dev/null
# Retry a POST safely: the same Idempotency-Key replays the first answer.
curl -sS -X POST https://bryanlabs.net/api/networks/v1/faucet/provider/request \
-H 'content-type: application/json' \
-H 'idempotency-key: 0b2f0d7e-faucet-cosmos1abc' \
-d '{"address":"cosmos1..."}'Errors
Every error is an RFC 9457 problem, served as application/problem+json. Branch on code, never on detail: the code is stable and documented, the detail is prose for people and may change. type links to the code's row below (or to the API's own docs for a service-specific code), instance is the path, request_id matches the header, and retry_after appears whenever the refusal is temporary. Validation problems add errors, a list of { path, message }.
HTTP/2 429
content-type: application/problem+json
x-request-id: 3b1e4f0a-7c2d-4e8b-9a61-1f0c2d3e4a5b
retry-after: 1812
ratelimit: "hourly";r=0;t=1812
ratelimit-policy: "hourly";q=1000;w=3600
{
"type": "https://bryanlabs.net/api-docs/conventions#rate_limited",
"title": "Too Many Requests",
"status": 429,
"detail": "Hourly quota used. Try again in 30 minutes.",
"code": "rate_limited",
"instance": "/api/snapshots/v1/chains/cosmoshub-4/download",
"request_id": "3b1e4f0a-7c2d-4e8b-9a61-1f0c2d3e4a5b",
"retry_after": 1812
}Compatibility: the bodies also carry error (same as detail) and success: false, the shape the APIs used before 2026-10-14. Both are deprecated and go away on 2027-04-01.
Shared codes
These codes mean the same thing on every API. Service-specific codes (a faucet refusal, a snapshot state) are listed in that API's OpenAPI document.
| Code | Status | When |
|---|---|---|
| bad_request | 400 | The request could not be read: malformed JSON, a bad parameter. |
| unauthorized | 401 | No session and no API key on an endpoint that needs one. |
| invalid_api_key | 401 | The bearer key is unknown, revoked or expired. |
| forbidden | 403 | Authenticated, but not allowed to do this. |
| insufficient_scope | 403 | The API key does not carry the scope the endpoint needs. |
| not_found | 404 | The resource does not exist. |
| route_not_found | 404 | No API route at that path. |
| method_not_allowed | 405 | The route exists but not for this HTTP method. |
| conflict | 409 | The request conflicts with current state, for example a limit on how many of something you can have. |
| gone | 410 | The endpoint was retired. The Link header points at the successor. |
| validation_failed | 422 | The body or query was well formed but invalid. `errors` lists each field. |
| precondition_required | 428 | Something must be sent first. The faucet uses pow_required with the challenge inline. |
| rate_limited | 429 | Over a quota. Retry-After and the RateLimit headers say when. |
| idempotency_key_invalid | 400 | Idempotency-Key is not 1 to 255 characters of letters, digits, '.', '_', ':' or '-'. |
| idempotency_key_reused | 422 | The same Idempotency-Key was sent with a different body. |
| idempotency_in_progress | 409 | The first request with this key is still running. Retry shortly. |
| internal_error | 500 | Our fault. Quote the request_id when reporting it. |
| upstream_error | 502 | A backing service answered badly. |
| unavailable | 503 | Temporarily down or paused. Retry-After is set. |
| upstream_timeout | 504 | A backing service did not answer in time. |
Rate limits
Limited endpoints describe their policy on every response with the IETF RateLimit header fields: RateLimit-Policy: "hourly";q=1000;w=3600 is the quota and window, RateLimit: "hourly";r=12;t=1812 is what is left and the seconds until it resets. The older X-RateLimit-Limit, -Remaining and -Reset headers are sent too. A refusal is 429 rate_limited with Retry-After; a paused or saturated service is 503 with Retry-After. Back off for at least that long, with jitter.
Published quotas are per client. Abuse controls that protect a service as a whole (for example the faucet's pool-wide allowance) are deliberately not published; a refusal from one of them is a plain busy with a retry time.
Idempotency
POST endpoints that create or spend accept an Idempotency-Key header (IETF draft). Retrying with the same key and the same body within 24 hours replays the first answer, marked Idempotent-Replayed: true, instead of acting twice. The same key with a different body is 422 idempotency_key_reused; a key whose first request is still running is 409 idempotency_in_progress. Keys are scoped to you and the endpoint, so a UUID per logical operation is the right choice. GET, PUT and DELETE are idempotent by design and ignore the header.
Deprecation
A retired endpoint keeps answering until its sunset date and says so on the wire: Deprecation (RFC 9745) carries the announcement date, Sunset (RFC 8594) the removal date, and Link points at the changelog entry and the successor. Removals are announced at least 90 days ahead. After the sunset date the path answers 410 gone.
Webhook signing
Deliveries from the Upgrades webhooks carry X-Bryanlabs-Timestamp (milliseconds since the epoch) and X-Bryanlabs-Signature: sha256=<hex>, an HMAC-SHA256 over "<timestamp>.<raw body>" with your endpoint's signing secret. Verify over the raw bytes before parsing, compare in constant time, and reject timestamps older than five minutes to defeat replays. Deliveries that fail are retried with backoff; make your handler idempotent on the event id.
import hmac, hashlib, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
ts = headers["X-Bryanlabs-Timestamp"]
sig = headers["X-Bryanlabs-Signature"].removeprefix("sha256=")
if abs(time.time() * 1000 - int(ts)) > 5 * 60 * 1000:
return False # replayed or clock skew beyond five minutes
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Transport
HTTPS only, TLS 1.2 or newer, HSTS at the edge, certificates from Let's Encrypt with CAA records restricting issuance. JSON bodies are UTF-8; send content-type: application/json. Responses are compressed when the client accepts it. Cross-origin reads are allowed on the public APIs; credentialed CORS is limited to BryanLabs origins. The security page covers how the platform is run and how to report a vulnerability; the status page shows live availability.