> ## Documentation Index
> Fetch the complete documentation index at: https://blank.build/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# API Conventions

> Base URL, pagination, rate limits, request IDs, caching, retries, deadlines, idempotency, and concurrency control for Blank API v2.

# API Conventions

Every operation in API v2 follows the same transport contract. Learn it once and it applies everywhere.

## Base URL and versioning

```
https://api.blank.build/api/v2
```

The version is in the path. Every response carries `X-Blank-Api-Version: 2`. Paths outside `/api/v2` are internal Blank planes with no stability contract — do not call them.

## Request headers

| Header                           | When                      | Notes                                                 |
| -------------------------------- | ------------------------- | ----------------------------------------------------- |
| `Authorization: Bearer <key>`    | Protected operations      | Server-side keys only                                 |
| `Accept: application/json`       | Always                    | The SDK sets it                                       |
| `Content-Type: application/json` | Requests with a body      | Anything else returns `415 unsupported_content_type`  |
| `Idempotency-Key`                | Every mutation            | Required; see [Idempotency](#idempotency)             |
| `If-Match: "<version>"`          | Version-guarded mutations | See [Optimistic concurrency](#optimistic-concurrency) |
| `If-None-Match: "<etag>"`        | Cacheable reads           | Returns `304` when unchanged                          |
| `traceparent` / `tracestate`     | Optional                  | W3C Trace Context, propagated into Blank telemetry    |

JSON request bodies are capped at **1,048,576 bytes**. A larger body returns `413 request_body_too_large`; an unparseable body returns `400 malformed_json`.

Version-guarded mutations return `428 precondition_required` when `If-Match` is absent, `400 precondition_invalid` when it is not one strong positive-integer ETag, and `412 precondition_failed` when a well-formed ETag is stale.

## Response headers

| Header                  | Meaning                                                                  |
| ----------------------- | ------------------------------------------------------------------------ |
| `X-Request-Id`          | Stable request identifier, `req_` plus 32 hex characters                 |
| `X-Blank-Api-Version`   | Always `2`                                                               |
| `X-Duration-Ms`         | Server-side processing time                                              |
| `traceparent`           | W3C Trace Context identifier for this request                            |
| `X-RateLimit-Limit`     | Ceiling of the tightest matching rate-limit dimension                    |
| `X-RateLimit-Remaining` | Requests left in the current window                                      |
| `X-RateLimit-Reset`     | Unix epoch second when that window resets                                |
| `X-RateLimit-Policy`    | Name of the rate policy applied to the operation                         |
| `Retry-After`           | Seconds to wait, on `429`, every `503`, and retryable in-progress `409`s |
| `Allow`                 | Supported methods when a known path returns `405 method_not_allowed`     |
| `Cache-Control`         | The operation's cache policy                                             |
| `ETag`                  | Present on cacheable reads and version-guarded resources                 |
| `Idempotent-Replayed`   | `true` when a mutation response was replayed from the idempotency record |
| `Vary`                  | Always includes `Authorization`                                          |

The SDK surfaces all of this on `result.metadata`:

```ts theme={null}
const result = await blank.tokens.list({ limit: 50 });

result.metadata.requestId; // X-Request-Id
result.metadata.durationMs; // X-Duration-Ms
result.metadata.apiVersion; // "2"
result.metadata.rateLimit; // { limit, remaining, resetAt, policy }
result.metadata.retryAfterSeconds;
result.metadata.idempotencyKey;
result.metadata.idempotentReplayed;
```

<Tip>
  Log `X-Request-Id` with every failure. It is the identifier Blank needs to
  trace what happened.
</Tip>

## Pagination and cursors

List operations return a cursor page:

```json theme={null}
{
  "data": [],
  "page": {
    "nextCursor": "eyJ2IjoxLCJzb3J0IjpbIjIwMjYtMDgtMDlUMDA6MDA6MDBaIl19",
    "hasMore": false
  }
}
```

* `cursor` — opaque, signed, and **bound to the query that produced it**. Never construct, decode, or mutate one. Changing filters mid-traversal invalidates the cursor.
* `limit` — integer from **1 to 100**, default **50**.
* Stop when `hasMore` is `false` or `nextCursor` is `null`.
* A malformed, tampered, or mismatched cursor returns `400 cursor_invalid`.

There are no offsets and no total counts. Ordering is stable and documented per operation.

```ts theme={null}
let cursor: string | undefined;

do {
  const page = await blank.marketData.trades(mint, { cursor, limit: 100 });
  for (const trade of page.data.data) process(trade);
  cursor = page.data.page.nextCursor ?? undefined;
} while (cursor !== undefined);
```

The SDK ships bounded async iterators for high-volume traversal:

```ts theme={null}
for await (const token of blank.tokens.iterate({}, { maxPages: 20 })) {
  console.log(token.mintAddress);
}
```

`maxPages` defaults to 100 and is capped at 10,000. The iterator throws rather than looping forever once the bound is reached, so pick a value that matches the dataset you expect.

Two list responses are **not** cursor paginated because they are hard-capped: the staking leaderboard and prediction standings each return at most 100 entries as `{ data: [...] }`.

## Rate limits

Every operation is assigned a named rate policy, reported in `X-RateLimit-Policy`:

`public-read-cheap`, `public-read-expensive`, `api-key-read`, `operation-read`, `prediction-submit`, `transaction-prepare`, `transaction-submit`, `webhook-admin`, `export`

Each policy is enforced across several dimensions at once. Anonymous requests are limited globally and per client IP. Authenticated requests are additionally limited per API key, per account, and per wallet. The response headers always describe the **tightest** dimension currently applying to you.

<Warning>
  Limits are environment configuration and change without a contract change. Do
  not hardcode numbers — read `X-RateLimit-Remaining` and `X-RateLimit-Reset`,
  and honour `Retry-After`.
</Warning>

Exceeding a limit returns `429 rate_limit_exceeded` with `Retry-After` and the rate-limit headers. If the limiter itself cannot be reached, requests fail closed with `503 rate_limit_unavailable`, which is retryable.

## Caching and conditional requests

Anonymous reads are cacheable and carry an `ETag`. Send it back on the next request:

```bash theme={null}
curl -s "https://api.blank.build/api/v2/tokens/$MINT" \
  -H 'If-None-Match: "aG9sZGVy..."' -i
```

A match returns `304 Not Modified` with no body and does not consume bandwidth. Cache lifetimes vary by operation, from `max-age=2` for market snapshots and trades up to `max-age=300` for the Solana manifest.

Authenticated reads are always `private, no-store`, and so is every error response. Never put them in a shared cache.

Some authenticated resources still return an `ETag` — but it is the resource's integer `version`, used with `If-Match` for [optimistic concurrency](#optimistic-concurrency), not a caching hint.

## Idempotency

**Every mutation requires an `Idempotency-Key` header.** There are no exceptions.

* Format: 8 to 128 printable ASCII characters.
* Retention: **24 hours** per key, scoped to the API key identity, HTTP method, and route.
* The claim, the state transition, the stored response, and the outbox record commit atomically in Postgres, so a replay returns exactly what the first attempt returned.

The SDK generates a key when you do not supply one and always exposes it:

```ts theme={null}
const result = await blank.predictions.create(
  { roundId, walletAddress, predictedPriceInSol: "0.00042" },
  { idempotencyKey: "prediction-2026-08-10-round-17" }
);

result.metadata.idempotencyKey;
result.metadata.idempotentReplayed; // true when replayed
```

<Warning>
  For anything you must be able to reconcile, generate the key yourself and
  persist it **before** the request. A generated key is lost if your process
  dies mid-call, and you cannot safely replay what you cannot name.
</Warning>

| Outcome                                   | Status          | Code                                                      |
| ----------------------------------------- | --------------- | --------------------------------------------------------- |
| Same key, same request, already completed | Original status | Response replayed with `Idempotent-Replayed: true`        |
| Same key, **different** request body      | 409             | `idempotency_key_reused`                                  |
| Same key, first attempt still in flight   | 409             | `idempotency_request_in_progress` (with `Retry-After: 1`) |
| Malformed key                             | 400             | `idempotency_key_invalid`                                 |

`BlankApiError` and `BlankNetworkError` both carry `idempotencyKey`, so an uncertain mutation can always be retried safely with the same key.

## Optimistic concurrency

Resources that can be updated concurrently carry an integer `version`. Guarded mutations require a strong `If-Match` header quoting that version:

```http theme={null}
If-Match: "3"
```

The SDK takes the number and formats the header for you:

```ts theme={null}
const endpoint = await blank.webhooks.get(endpointId);

await blank.webhooks.update(endpointId, {
  status: "disabled",
  version: endpoint.data.version,
});
```

A stale version returns `412 precondition_failed` for webhook endpoints and `412 transaction_intent_version_conflict` for transaction intents. Re-read the resource, re-apply your change, and retry.

## Retries and deadlines

The SDK retries **at most twice**, with full-jitter backoff, for:

* network failures and timeouts
* `429`, `502`, `503`, and `504`

A mutation is retried only when it carries an idempotency key. Automatic waits are capped at 30 seconds; a longer `Retry-After` is surfaced to you instead of being slept through.

Deadlines are per attempt, not per call:

```ts theme={null}
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);

const result = await blank.tokens.list(
  { limit: 50 },
  { signal: controller.signal, timeoutMs: 5_000, retries: 1 }
);
```

* `timeoutMs` — per-attempt deadline. Default 30,000; valid range 100 to 120,000.
* `retries` — `0`, `1`, or `2`. Defaults to `2`, and can be set per client or per call.
* `signal` — an `AbortSignal` that cancels the call and any pending backoff.

Both can be set once on the client:

```ts theme={null}
const blank = new BlankClient({
  apiKey: process.env.BLANK_API_KEY,
  timeoutMs: 10_000,
  retries: 1,
});
```

If you implement retries yourself, use exponential backoff with jitter, cap total attempts, and always reuse the same idempotency key on a mutation. Do not retry a `4xx` except `429` or `409 idempotency_request_in_progress`; both carry `Retry-After`.

## Numeric precision

Prices, SOL amounts, and other decimal quantities are **canonical decimal strings** such as `"0.00001234"`. Raw on-chain amounts and lamport values are **unsigned integer strings** such as `"1000000000"`.

They are strings on purpose: IEEE 754 doubles cannot represent them exactly. Parse them with `BigInt` or a decimal library, never with `Number`.

## Response validation

The SDK validates every success response against the generated operation schema before returning it. It also validates every error's HTTP status, code, canonical title, and type URI against that operation. An invalid success raises `BlankNetworkError`; an invalid error becomes `BlankApiError` with `invalid_error_response`. This is why the SDK version should track the API contract — see the [OpenAPI reference](/docs/api-reference/introduction).

## Errors

Every error is RFC 9457 `application/problem+json`. See [Errors](/docs/reference/errors) for the envelope and the full code catalogue.
