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

# Price Predictions

> Read prediction rounds, check eligibility, and submit price predictions programmatically with the Blank API v2.

# Price Predictions

Price Prediction is the largest automation surface in the v2 API. You can read rounds and results anonymously, submit for the wallet your API key is pinned to, or let an end user submit from a partner site by signing a short-lived delegated intent with their verified wallet.

For the product rules — how rounds are scheduled, how prizes are funded, and how winners are ranked — see [Price Prediction](/docs/launching-tokens/price-prediction).

## Operations

| Operation                         | HTTP                                                         | SDK                                                       | Auth      | Scope                  | Notes                                                                                 |
| --------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- | --------- | ---------------------- | ------------------------------------------------------------------------------------- |
| `listPredictionRounds`            | `GET /tokens/{mint}/prediction-rounds`                       | `blank.predictions.rounds(mint, query)`                   | Anonymous | —                      | Newest-first, cursor paginated. `public, max-age=5, stale-while-revalidate=20` + ETag |
| `getPredictionRound`              | `GET /tokens/{mint}/prediction-rounds/{roundId}`             | `blank.predictions.round(mint, roundId)`                  | Anonymous | —                      | `public, max-age=5, stale-while-revalidate=20` + ETag                                 |
| `listRoundPredictions`            | `GET /tokens/{mint}/prediction-rounds/{roundId}/predictions` | `blank.predictions.entries(mint, roundId)`                | Anonymous | —                      | Privacy-aware union. `public, max-age=5, stale-while-revalidate=20` + ETag            |
| `listPredictionStandings`         | `GET /tokens/{mint}/prediction-rounds/{roundId}/standings`   | `blank.predictions.standings(mint, roundId)`              | Anonymous | —                      | `public, max-age=10, stale-while-revalidate=30` + ETag                                |
| `getPredictionEligibility`        | `GET /tokens/{mint}/prediction-rounds/{roundId}/eligibility` | `blank.predictions.eligibility(mint, roundId)`            | API key   | `predictions:read`     | `private, no-store`                                                                   |
| `getOwnedPrediction`              | `GET /predictions/{predictionId}`                            | `blank.predictions.get(predictionId)`                     | API key   | `predictions:read`     | `private, no-store`                                                                   |
| `createPrediction`                | `POST /predictions`                                          | `blank.predictions.create(input, options)`                | API key   | `predictions:write`    | `201`, durable idempotency required, rate policy `prediction-submit`                  |
| `createDelegatedPredictionIntent` | `POST /prediction-intents`                                   | `blank.predictions.createDelegatedIntent(input, options)` | API key   | `predictions:delegate` | Returns the exact five-minute UTF-8 message for the end user's wallet to sign         |
| `createDelegatedPrediction`       | `POST /delegated-predictions`                                | `blank.predictions.createDelegated(input, options)`       | API key   | `predictions:delegate` | Verifies and atomically consumes the signed intent                                    |

Base URL is `https://api.blank.build/api/v2`. See [Scopes](/docs/for-developers/scopes) for granting `predictions:read`, `predictions:write`, and `predictions:delegate`, and [Conventions](/docs/for-developers/conventions) for pagination, caching, rate limits, retries, and idempotency.

## Client setup

API keys are server-side only. Every example on this page is Node.js server code.

```ts theme={null}
import { BlankClient } from "@blankdotbuild/sdk";

const blank = new BlankClient({ apiKey: process.env.BLANK_API_KEY });
```

Every SDK call resolves to `{ data, metadata, response }`. All prices are canonical decimal strings and all lamport amounts are unsigned integer strings — parse them with `BigInt` or a decimal library, never `Number`.

## Rounds

`listPredictionRounds` returns rounds newest-first with `cursor` + `limit` (1–100, default 50).

```ts theme={null}
const rounds = await blank.predictions.rounds(mint, { limit: 10 });

const open = rounds.data.data.find((round) => round.status === "open");
if (!open) {
  throw new Error("No open round for this token right now");
}
```

| Field                                           | Type                     | Notes                                                           |
| ----------------------------------------------- | ------------------------ | --------------------------------------------------------------- |
| `id`                                            | UUID                     | Round identifier.                                               |
| `mintAddress`                                   | Base58 address           | The token this round belongs to.                                |
| `roundNumber`                                   | Positive integer         | Sequence number within the token.                               |
| `status`                                        | Enum                     | `scheduled`, `open`, `locked`, `settling`, `settled`, `voided`. |
| `opensAt`                                       | RFC 3339                 | When submissions start being accepted.                          |
| `locksAt`                                       | RFC 3339                 | When submissions stop.                                          |
| `settlementWindowStart` / `settlementWindowEnd` | RFC 3339                 | The window the settlement price is taken from.                  |
| `baselinePriceInSol`                            | Decimal string, nullable | Reference price for the round.                                  |
| `settlementPriceInSol`                          | Decimal string, nullable | Populated only after settlement.                                |
| `prizePoolLamports`                             | Unsigned integer string  | Prize pool funded for the round.                                |
| `predictionCount`                               | Integer                  | Number of submitted predictions.                                |
| `settledAt`                                     | RFC 3339, nullable       | Set when settlement completes.                                  |

You can only submit while a round is `open`. `locksAt` is the scheduled boundary — treat the server's `status` as authoritative rather than comparing clocks yourself, since the lock is a durable state transition, not a timestamp comparison.

```ts theme={null}
const round = await blank.predictions.round(mint, roundId);

if (round.data.status === "settled") {
  console.log("Settlement price", round.data.settlementPriceInSol);
}
```

If the token has never had Price Prediction enabled, `listPredictionRounds` returns `404 prediction_token_not_enabled`.

## Privacy model

This is the part most integrations get wrong. Exact predicted values are withheld until the round durably locks, so nobody can copy or front-run other entries.

`listRoundPredictions` returns a discriminated union on `visibility`:

```ts theme={null}
const entries = await blank.predictions.entries(mint, roundId);

if (entries.data.visibility === "aggregate") {
  // Round has not durably locked. Only the count is public.
  console.log(`${entries.data.participantCount} predictions so far`);
} else {
  // visibility === "exact": the round has locked.
  for (const entry of entries.data.data) {
    console.log(entry.username, entry.predictedPriceInSol, entry.rank);
  }
}
```

Before the lock:

```json theme={null}
{ "visibility": "aggregate", "participantCount": 187 }
```

After the lock:

```json theme={null}
{
  "visibility": "exact",
  "participantCount": 187,
  "data": [
    {
      "id": "b1c5a9f2-2d43-4c8e-9f11-0a7c3d61be40",
      "walletAddress": "So11111111111111111111111111111111111111112",
      "username": "alice",
      "predictedPriceInSol": "0.00001234",
      "rank": 1,
      "distancePpm": "412",
      "submittedAt": "2026-08-10T09:12:03Z"
    }
  ]
}
```

`data` holds at most 100 `PublicPredictionEntry` items: `id`, `walletAddress`, `username`, `predictedPriceInSol`, `rank` (nullable), `distancePpm` (nullable), and `submittedAt`.

<Warning>
  Never branch on `locksAt` having passed to decide whether exact values will be
  present. Branch on `visibility`. The switch happens on the durable lock
  transition, which is what the API enforces.
</Warning>

### Standings

`listPredictionStandings` returns `{ data: [...] }` with at most 100 ranked entries. It returns `409 prediction_exact_values_locked` until the round has durably locked.

```ts theme={null}
import { BlankApiError } from "@blankdotbuild/sdk";

try {
  const standings = await blank.predictions.standings(mint, roundId);
  for (const standing of standings.data.data) {
    console.log(standing.rank, standing.username, standing.payoutLamports);
  }
} catch (error) {
  if (
    error instanceof BlankApiError &&
    error.code === "prediction_exact_values_locked"
  ) {
    // Round is still open. Poll again after it locks.
  } else {
    throw error;
  }
}
```

Each standing carries the `PublicPredictionEntry` fields plus a non-null `rank` and `payoutLamports` (unsigned integer string, nullable — `null` when no payout applies).

### Your own prediction

`getOwnedPrediction` returns your full immutable prediction at any time, including before the round locks. The privacy rules apply to other people's entries, not to yours.

| Field                 | Type                              | Notes                                                         |
| --------------------- | --------------------------------- | ------------------------------------------------------------- |
| `id`                  | UUID                              | Prediction identifier.                                        |
| `roundId`             | UUID                              | The round it belongs to.                                      |
| `mintAddress`         | Base58 address                    | The token.                                                    |
| `walletAddress`       | Base58 address                    | Your pinned wallet.                                           |
| `username`            | String                            | The Blank username at submission time.                        |
| `predictedPriceInSol` | Decimal string                    | The submitted value.                                          |
| `status`              | Enum                              | `active`, `ineligible`, `voided`.                             |
| `baselineEligible`    | Boolean                           | Whether the wallet met the balance requirement at submission. |
| `baselineBalanceRaw`  | Unsigned integer string           | Raw token balance recorded at submission.                     |
| `baselineBalanceSlot` | Unsigned integer string, nullable | Finalized slot the baseline balance was read at.              |
| `settlementEligible`  | Boolean, nullable                 | Re-checked at settlement; `null` until then.                  |
| `distancePpm`         | Unsigned integer string, nullable | Distance from the settlement price in parts per million.      |
| `rank`                | Integer, nullable                 | Final rank once ranked.                                       |
| `submissionSource`    | Enum                              | `web` or `api_v2`.                                            |
| `submittedAt`         | RFC 3339                          | Submission timestamp.                                         |

Predictions created through this API always carry `submissionSource: "api_v2"`, which is how you tell your own automated entries apart from ones made in the Blank app.

## Eligibility

`getPredictionEligibility` evaluates your API key's pinned wallet against finalized chain state and returns the evidence behind the verdict.

```ts theme={null}
const eligibility = await blank.predictions.eligibility(mint, roundId);

if (!eligibility.data.eligible) {
  console.log("Not eligible:", eligibility.data.reasons.join(", "));
}
```

| Field               | Type                              | Notes                                                                                                                           |
| ------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `roundId`           | UUID                              | The round evaluated.                                                                                                            |
| `walletAddress`     | Base58 address                    | Your key's pinned wallet.                                                                                                       |
| `eligible`          | Boolean                           | The verdict.                                                                                                                    |
| `roundStatus`       | Enum                              | Current round status used for the verdict.                                                                                      |
| `locksAt`           | RFC 3339                          | The lock deadline. An `open` round at or past this time is not open for submissions.                                            |
| `reasons`           | Array of enum                     | `username_required`, `wallet_not_verified`, `insufficient_balance`, `round_not_open`, `token_not_enabled`. Empty when eligible. |
| `balanceRaw`        | Unsigned integer string, nullable | Finalized token balance used for the check.                                                                                     |
| `minimumBalanceRaw` | Unsigned integer string           | Minimum raw balance required, currently one whole token.                                                                        |
| `evaluationSlot`    | Unsigned integer string, nullable | Finalized slot the balance was read at.                                                                                         |
| `evaluatedAt`       | RFC 3339                          | When the evaluation ran.                                                                                                        |

This route can return `503 prediction_balance_unavailable` when finalized balance data cannot be read. That is retryable — back off and try again.

<Note>
  Eligibility is advisory evidence, not a reservation. The server re-verifies
  every condition when you submit, so an `eligible: true` response can still be
  followed by a `409` if the round locks or your balance drops in between. Check
  eligibility to give users a clear reason up front, then handle the submit
  errors anyway.
</Note>

Eligibility does not stop at the first failure. For example, a closed round and a zero token balance produce both `round_not_open` and `insufficient_balance`. Submission errors keep one primary `code` for normal branching and include any additional failures discovered during the same evaluation in `problem.causes`.

## Submitting a prediction

`createPrediction` takes a strict body — extra properties are rejected.

| Field                 | Rules                                                                                                                       |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `roundId`             | UUID of an `open` round.                                                                                                    |
| `walletAddress`       | Base58 Solana address. Must equal your API key's pinned wallet, otherwise `403 prediction_wallet_mismatch`.                 |
| `predictedPriceInSol` | Canonical decimal string. Must be positive (`"0"` is rejected), at most 12 integer digits and at most 18 fractional digits. |

Canonical means no leading zeros on the integer part and no trailing zeros in the fraction: send `"0.00001234"`, not `"0.000012340"`.

```ts theme={null}
const submission = await blank.predictions.create(
  {
    roundId: open.id,
    walletAddress: process.env.BLANK_WALLET_ADDRESS!,
    predictedPriceInSol: "0.00001234",
  },
  { idempotencyKey: "prediction-round-42-attempt-1" }
);

console.log(submission.data.id, submission.data.status);
```

A successful call returns `201` with the immutable `Prediction`.

<Warning>
  Predictions cannot be edited or withdrawn. One prediction per wallet per round
  — a second attempt returns `409 prediction_already_submitted`. Validate the
  value in your own code before you send it.
</Warning>

### Idempotency

`createPrediction` requires a durable `Idempotency-Key` header: 8–128 printable ASCII characters. If you do not pass `idempotencyKey` in the mutation options, the SDK generates one and exposes it as `result.metadata.idempotencyKey`.

Persist the key **before** you send the request, and reuse it on every retry. A generated key that only exists in memory is useless if the process dies mid-flight, which is exactly the case idempotency exists for.

* Retention is 24 hours.
* A replayed response carries `Idempotent-Replayed: true`; the SDK surfaces it as `result.metadata.idempotentReplayed`.
* Reusing a key with a different body returns `409 idempotency_key_reused`.
* Retrying while the first attempt is still in flight returns `409 idempotency_request_in_progress` with `Retry-After: 1`.

The full contract is in [Conventions](/docs/for-developers/conventions).

### Raw HTTP

Run this from a server shell, never a browser:

```bash theme={null}
curl -sX POST "https://api.blank.build/api/v2/predictions" \
  -H "Authorization: Bearer $BLANK_API_KEY" \
  -H "Idempotency-Key: prediction-round-42-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{
    "roundId": "7d2c1a4e-8b93-4a17-9c05-2f6e8d1b3a90",
    "walletAddress": "So11111111111111111111111111111111111111112",
    "predictedPriceInSol": "0.00001234"
  }'
```

### Submission errors

| Status | Code                              | Fix                                                                                                         |
| ------ | --------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| 403    | `prediction_wallet_mismatch`      | The body's `walletAddress` is not the key's pinned wallet. Send the pinned wallet.                          |
| 404    | `prediction_round_not_found`      | Wrong `roundId`, or the round belongs to another token. Re-read the round list.                             |
| 409    | `prediction_round_not_open`       | The round is `scheduled`, `locked`, `settling`, `settled`, or `voided`. Wait for the next `open` round.     |
| 409    | `prediction_token_not_enabled`    | Price Prediction is not enabled for this token. `listPredictionRounds` reports the same condition as `404`. |
| 409    | `prediction_wallet_not_verified`  | Verify the wallet on the Blank account behind the key.                                                      |
| 409    | `prediction_username_required`    | Set a username on the Blank account before submitting.                                                      |
| 409    | `prediction_insufficient_balance` | The pinned wallet does not hold enough of the token. Top up and re-check eligibility.                       |
| 409    | `prediction_already_submitted`    | One prediction per wallet per round. Read it back with `getOwnedPrediction`.                                |
| 422    | `prediction_price_out_of_range`   | The value is not positive, or exceeds 12 integer or 18 fractional digits.                                   |
| 503    | `prediction_balance_unavailable`  | Finalized balance data could not be read. Retryable — back off and retry with the same idempotency key.     |

Read routes surface `404 prediction_round_not_found` for unknown rounds, `404 prediction_not_found` when `getOwnedPrediction` is called with an ID your key does not own, and `409 prediction_exact_values_locked` when exact values are still withheld. The shared `401 invalid_api_key` and `403 insufficient_scope` apply to every authenticated route. See [Error Model](/docs/reference/errors) for the problem-details body and [Troubleshooting](/docs/reference/troubleshooting) for diagnosis.

## Delegated submissions

Use delegation when users should enter a prediction on your site under their own Blank account. Create a dedicated server-side API key with only `predictions:delegate`. Never send that key or construct `BlankClient` in the browser.

The flow is server → browser wallet → server:

1. Your server prepares an intent for the exact round, wallet, and price.
2. Your browser gives `intent.message` to the connected wallet's `signMessage` method as UTF-8 bytes.
3. Your browser base58-encodes the 64-byte Ed25519 signature and sends the intent ID and signature to your server.
4. Your server submits them to Blank with a second durable idempotency key.

Prepare the intent on your server:

```ts theme={null}
const intent = await blank.predictions.createDelegatedIntent(
  {
    roundId,
    walletAddress: endUserWallet,
    predictedPriceInSol: "0.00001234",
  },
  { idempotencyKey: `prediction-intent:${checkoutId}` }
);

// Return only these public values to your browser.
return {
  intentId: intent.data.id,
  message: intent.data.message,
  expiresAt: intent.data.expiresAt,
};
```

Ask the connected wallet to sign the exact message in the browser. The example uses `bs58`; the Blank SDK remains server-only.

```ts theme={null}
import bs58 from "bs58";

const signatureBytes = await wallet.signMessage(
  new TextEncoder().encode(message)
);
const signature = bs58.encode(signatureBytes);

await fetch("/api/predictions/submit", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ intentId, signature }),
});
```

Submit from your server:

```ts theme={null}
const prediction = await blank.predictions.createDelegated(
  { intentId, signature },
  { idempotencyKey: `delegated-prediction:${intentId}` }
);

console.log(prediction.data.id, prediction.data.walletAddress);
```

An intent expires after five minutes or when the round locks, whichever happens first. It belongs to the API key that created it and can be consumed once. The response and signed message identify the authoritative token mint and Solana network as well as the round, wallet, and price. The stored message is authoritative: reconstructing it, changing any field, or signing a different byte sequence fails verification.

The signing wallet must already be the verified wallet of a Blank account with a username and the required finalized token balance. The existing one-prediction-per-account-per-round rule still applies. The integration receives the created `Prediction` in the submission response, while owner-scoped reads and prediction webhooks remain private to the signing user's Blank account.

| Status | Code                               | Fix                                                                                         |
| ------ | ---------------------------------- | ------------------------------------------------------------------------------------------- |
| 403    | `prediction_authorization_invalid` | Re-sign the exact unmodified `intent.message` with `intent.walletAddress`.                  |
| 404    | `prediction_intent_not_found`      | The ID is wrong or the intent belongs to another API key. Prepare a new intent.             |
| 409    | `prediction_intent_expired`        | Prepare and sign a new intent while the round is still open.                                |
| 409    | `prediction_intent_consumed`       | The intent was already used. Replay the original request with its original idempotency key. |
| 409    | `prediction_wallet_not_verified`   | The signing wallet must be verified on a Blank account.                                     |

All normal prediction eligibility errors can also be returned at submission time. A valid signature proves consent; it does not reserve eligibility or bypass the round, username, balance, or uniqueness rules.

When several requirements fail, inspect `problem.causes` as well as the primary `problem.code`. Each cause has a canonical `code`, `title`, `detail`, and optional `context`. Balance failures include `balanceRaw`, `minimumBalanceRaw`, and `evaluationSlot`; closed-round failures include `roundStatus` and `locksAt`.

## End to end

Find the open round, check eligibility, submit with a caller-owned durable key, then read the prediction back.

```ts theme={null}
import { randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { BlankApiError, BlankClient } from "@blankdotbuild/sdk";

const blank = new BlankClient({ apiKey: process.env.BLANK_API_KEY });

const mint = process.env.BLANK_MINT!;
const wallet = process.env.BLANK_WALLET_ADDRESS!;

async function submitPrediction(predictedPriceInSol: string) {
  // 1. Find the open round.
  const rounds = await blank.predictions.rounds(mint, { limit: 10 });
  const round = rounds.data.data.find(
    (candidate) => candidate.status === "open"
  );
  if (!round) {
    return { submitted: false as const, reason: "no_open_round" };
  }

  // 2. Check eligibility so you can report a clear reason before spending a submit.
  const eligibility = await blank.predictions.eligibility(mint, round.id);
  if (!eligibility.data.eligible) {
    return {
      submitted: false as const,
      reason: eligibility.data.reasons.join(","),
    };
  }

  // 3. Persist a durable idempotency key BEFORE the request, then submit.
  const idempotencyKey = await loadOrCreateKey(round.id);

  let predictionId: string;
  try {
    const created = await blank.predictions.create(
      { roundId: round.id, walletAddress: wallet, predictedPriceInSol },
      { idempotencyKey }
    );
    predictionId = created.data.id;

    if (created.metadata.idempotentReplayed) {
      console.log("Replayed an earlier attempt, no new prediction was created");
    }
  } catch (error) {
    if (error instanceof BlankApiError) {
      if (error.code === "idempotency_request_in_progress") {
        // First attempt is still running. Retry after Retry-After with the same key.
        return { submitted: false as const, reason: "in_progress" };
      }
      if (error.code === "prediction_already_submitted") {
        return { submitted: false as const, reason: "already_submitted" };
      }
    }
    throw error;
  }

  // 4. Read the immutable record back.
  const prediction = await blank.predictions.get(predictionId);
  return { submitted: true as const, prediction: prediction.data };
}

// One durable key per round, written before the request is issued. Swap the
// filesystem for whatever store you already run — the property that matters is
// that the key survives a crash so a retry replays instead of double-submitting.
const keyDir = process.env.BLANK_IDEMPOTENCY_DIR ?? "./.blank-idempotency";

async function loadOrCreateKey(roundId: string) {
  await mkdir(keyDir, { recursive: true });
  const file = path.join(keyDir, `${roundId}.key`);
  const key = `prediction-${roundId}-${randomUUID()}`;

  try {
    // "wx" fails if the file exists, so the first writer wins the race.
    await writeFile(file, key, { flag: "wx" });
    return key;
  } catch {
    return (await readFile(file, "utf8")).trim();
  }
}
```

The returned `prediction.submissionSource` is `"api_v2"`, and `prediction.status` starts as `active`. `settlementEligible`, `distancePpm`, and `rank` stay `null` until the round settles.

## Reacting to rounds with webhooks

Polling works, but webhooks are how you drive a bot without a timer. Three event types cover the prediction lifecycle:

| Event type                                | Fires when                                                                       |
| ----------------------------------------- | -------------------------------------------------------------------------------- |
| `build.blank.prediction.submitted.v1`     | A prediction is accepted.                                                        |
| `build.blank.prediction.round.locked.v1`  | A round durably locks. Exact values and standings become readable at this point. |
| `build.blank.prediction.round.settled.v1` | A round settles. `settlementPriceInSol`, ranks, and payouts are final.           |

A typical loop: subscribe to `round.locked` to fetch standings the moment they open up, and to `round.settled` to record results and payouts. Subscribe to `prediction.submitted` if you want a confirmation signal independent of your submit call's HTTP response.

See [Webhooks](/docs/for-developers/webhooks) for endpoint registration, signature verification, replay, and delivery retries.
