PredictorSDK
Guides

Error Handling

HTTP status codes and error response format.

When a request fails, the API returns an error response with an HTTP status code and a JSON body.

Error response format

Every error, on every endpoint, has the same body:

{
  "code": "invalid_parameter",
  "message": "invalid provider \"sx_bet\": must be one of kalshi, polymarket, predict, sxbet, hyperliquid, alpha-arcade, prophetx, limitless, pred",
  "status_code": 400,
  "param": "provider"
}
  • code is a stable machine-readable reason. Branch on it: a code is never renamed or reused, and new ones may be added.
  • message says what went wrong and how to fix it, for people. It quotes the value you sent where there is one. Show it or log it; do not parse it.
  • status_code repeats the HTTP status, for logs that keep only the body.
  • param names the query or path parameter the error concerns, when there is one.
  • errors lists every problem when a request has more than one, such as a lookup batch with several bad identifiers. message describes the first:
{
  "code": "invalid_parameter",
  "message": "invalid source_id \"kalshi:yes\": \"yes\" names a side within one kalshi market, not a market; look the market up by its market_id (and 1 more problem; see errors)",
  "status_code": 400,
  "param": "source_id",
  "errors": [
    { "param": "source_id", "value": "kalshi:yes", "message": "invalid source_id \"kalshi:yes\": …" },
    { "param": "source_id", "value": "bogus:1", "message": "invalid source_id \"bogus:1\": must be {provider}:{id}, …" }
  ]
}

A 409 adds candidates and a 402 adds action, required_tier and current_tier (see below).

Request ids

Every response, a success or any error, carries an X-Request-Id header: a short opaque string that names that one request. When a call fails or surprises you, quote the id when you contact support and we can find the request from it. Log it beside the status code of any request you may need to ask about.

  • The API generates it for every request, so a retry of a failed request gets a new one.
  • It is opaque. Do not parse it or rely on its length or prefix.
  • The API ignores an X-Request-Id you send and does not echo it back, so it is not a way to tag your own requests.

Every SDK error carries the response headers; see Reading the request id.

Status codes

StatuscodeMeaning
400unknown_parameterThe endpoint does not read that query parameter. message lists the ones it accepts
400invalid_parameterA value is malformed, empty, out of range, sent more than once, or contradicts another parameter (e.g. a source_id without its {provider}: prefix)
400missing_parameterA required parameter is absent
400invalid_cursorA cursor that is malformed, stale, or from another endpoint or filter set. Start again without it
400invalid_identifierA market_id or event_id that cannot exist on the provider it resolves to, or whose {provider}: prefix names no provider
400upstream_rejectedThe venue rejected the request
401missing_api_keyNo Authorization: Bearer <key> header. message says what was sent instead, if anything
401invalid_api_keyThe key is not valid, including any key longer than 256 characters
402payment_requiredFree monthly allowance is exhausted, or a paid subscription is in a payment-recovery state — see Plans & Limits
403forbiddenThe key lacks the permission this endpoint requires
404route_not_foundNo route matches the path. Check the path before you check the ID
404market_not_foundThe market does not exist on the selected or inferred provider
404event_not_foundThe event does not exist on the selected or inferred provider
404profile_not_foundNo Polymarket profile matches the address or username
405method_not_allowedThe path exists but not under the HTTP method you sent — read the Allow header
409ambiguous_identifierA bare market_id or event_id resolves on more than one provider, so no single one can honestly be returned — see Events & Markets
429rate_limitedThe key exceeded its per-minute limit. Honour Retry-After
429usage_limit_exceededThe key used up an allowance that does not refill within the minute
429upstream_rate_limitedThe venue rate-limited the request
502upstream_unavailablePredictorSDK could not get a valid response from a venue or from API-key verification
503service_unavailableA reader, service dependency, or endpoint configuration is temporarily unavailable

Routing errors

Three of these come from the router rather than from an endpoint. They can arrive on any path, and none needs a valid API key — the router answers before the key is read.

  • A path matching no route returns 404 with code route_not_found. An endpoint's own 404 (market_not_found, event_not_found) means the identifier was looked up and missed.
  • A path that exists but not under the method you sent returns 405 with code method_not_allowed, an Allow header naming the methods that path does accept, and the same list in message. Every matching/markets/events reference endpoint here is a GET (POST /v1/account/billing/portal is the documented billing exception).
  • A request carrying a query parameter the endpoint does not read returns 400 with code unknown_parameter. One that sends a parameter twice (other than the repeatable event_id and source_id of the matching lookup), or a filter or identifier with an empty value, returns 400 with code invalid_parameter.
  • A query string that is not &-separated key=value pairs returns 400 with code invalid_parameter, quoting the pair at fault: a ; used as a separator, or a % not followed by two hex digits. Percent-encode a literal ; as %3B and % as %25.

A generated SDK method sends its own declared path and method, so it never meets the 404 or 405. Every operation declares 400, so the parameter rejections arrive as that operation's typed bad-request error.

Unknown, repeated and empty parameters are rejected, not ignored

Every parameter you send has to be one the endpoint declares, sent once (the matching lookup's event_id and source_id are the exception), and a filter or identifier has to carry a value. This is deliberate and it applies to every route: a silently dropped parameter returns a full unfiltered response that looks filtered, which is harder to diagnose than an error. The concrete cases are ?platform= on GET /v1/markets — the natural guess for a provider filter — and ?provider= left empty by a template, which used to return the whole catalogue with a 200 on it.

GET /v1/markets?platform=sxbet
400 {"code": "unknown_parameter",
     "message": "This endpoint does not read the query parameter \"platform\". It accepts: category, cursor, limit, provider.",
     "status_code": 400, "param": "platform", "errors": [...]}

The parameter to reach for on that endpoint is provider — see Filtering. A generated SDK only sends the parameters each method declares, unless you add others through its raw request options.

A parameter this API renamed answers the same 400 and names its replacement, so an older hand-built request learns the new spelling from the error: platform became provider on market detail and events (2026-10-09), and the crypto-prices pagination_key became cursor:

GET /v1/events/predict:1607914?platform=predict
400 {"code": "unknown_parameter",
     "message": "This endpoint does not read the query parameter \"platform\". It accepts: provider. \"platform\" was replaced by ?provider={provider}, which takes the same values.",
     "status_code": 400, "param": "platform", "errors": [...]}

Rate-limit headers

Authenticated responses include the current key's rate-limit state:

HeaderUnitMeaning
X-RateLimit-LimitrequestsRequests allowed in a 60-second window. Equals your plan's rate_limit_per_min from GET /v1/plans
X-RateLimit-RemainingrequestsAdvisory estimate. Not a countdown — see below
X-RateLimit-ResetUnix secondsEnd of the current window. A step function: the same value on every response inside one window
Retry-AftersecondsWhole seconds to wait; included on API-key 429 responses and optional on upstream 429 responses

Unauthenticated routes — GET /v1/plans, and the router-level 404 and 405 above — carry none of them. The limiter is only consulted once a key has been read.

X-RateLimit-Reset is a Unix epoch in seconds, not milliseconds. The generated SDK retry layers accept both the documented seconds form and the legacy millisecond epoch that older deployments emitted. They cap retry delays at 60 seconds and retry 408, 429, and 5xx responses (never a 409, which answers the same way every time).

Building a local limiter

Do not pace from X-RateLimit-Remaining. The limiter's budget recovers continuously as earlier requests age out rather than refilling all at once, so the value legitimately moves in both directions: between two strictly sequential responses inside one X-RateLimit-Reset window it may repeat, skip values, or increase. Measured on 2026-08-26 against a 1000 req/min key, 120 sequential requests over three windows: 42 increases, the largest +8. It also runs ahead of your consumption — by the end of a fast burst it over-stated the true remaining budget by up to 44 requests — and the gap widens the faster you send, so it is least accurate exactly when you would consult it. Treat a low value as a reason to slow down; never treat a high one as permission to burst.

Two consequences worth stating outright, because both contradict the usual fixed-window mental model:

  • X-RateLimit-Reset is not a refill instant. Nothing jumps back to X-RateLimit-Limit when it passes; four boundary crossings measured on the same day moved the reported budget by +5, 0, +2 and +2. Use it as an upper bound on how long a 429 can last.
  • Consuming exactly X-RateLimit-Limit requests and then waiting for X-RateLimit-Reset is therefore not the contract. Spread the same budget over a rolling 60 seconds instead.

What does work is a client-side sliding window, which needs nothing from X-RateLimit-Remaining at all:

  1. Read rate_limit_per_min for your plan from GET /v1/plans, or take X-RateLimit-Limit from any authenticated response. They are the same number.
  2. Keep your own timestamps for the last 60 seconds of requests and refuse to send when that count reaches the limit — or simply space requests 60 / limit seconds apart, which is the same thing without the bookkeeping.
  3. Back off whenever a 429 arrives, honouring Retry-After. The 429 and Retry-After are authoritative; they are computed from the limiter's own window instant and never disagree with X-RateLimit-Reset.

402 Payment Required response

The response body includes an action field so clients can render the right follow-up:

actionMeaningRecommended UX
upgrade_planThe caller is on a lower tier than the endpoint requires, or a Free caller exhausted the monthly request allowance.Route them to /pricing or a plan-upgrade CTA.
resolve_paymentThe caller had a paid subscription, but it's now in a payment-recovery state (past_due, unpaid, paused, incomplete). The backend has already downgraded them to Free.Route them to a signed-in billing/settings flow that can call POST /v1/account/billing/portal; that endpoint requires an authenticated account session, not the API key that received the 402.

Both variants return the same 402 status and include required_tier / current_tier so clients can keep a single handler with an action switch inside.

When a Free caller exhausts the monthly allowance, the response also includes included_requests_per_month and current_period_requests.

Handling errors in the SDK

All SDKs throw typed errors for known HTTP status codes — including PaymentRequiredError for 402, which exposes the action, required_tier, current_tier, and (on Free-cap 402s) included_requests_per_month + current_period_requests fields directly on the error body.

import { PredictorSDK, PredictorSDKClient } from "@predictorsdk/client";

const client = new PredictorSDKClient({
  token: "YOUR_API_KEY",
});

try {
  // A venue ID without its provider prefix is a 400.
  await client.lookupSportsMatchingMarkets({ sourceId: "KXNBAGAME-26OCT20OKCSAS" });
} catch (error) {
  if (error instanceof PredictorSDK.BadRequestError) {
    console.log("Bad request:", error.body.code, error.body.message);
  } else if (error instanceof PredictorSDK.PaymentRequiredError) {
    if (error.body.action === "resolve_payment") {
      // Subscription in payment recovery — send to billing portal
    } else {
      // Plan or allowance ceiling — send to /pricing
    }
  } else if (error instanceof PredictorSDK.TooManyRequestsError) {
    // Rate limited — back off and retry
  } else if (error instanceof PredictorSDK.UnauthorizedError) {
    // Invalid API key
  }
}

Reading the request id

Each SDK exposes the response headers on its errors, so the id is one lookup away. It is missing only when no response arrived at all (a timeout, or a connection that failed), because then there is no request on our side to name.

import { PredictorSDKError } from "@predictorsdk/client";

try {
  await client.getCategories();
} catch (error) {
  if (error instanceof PredictorSDKError) {
    // The `x-request-id` response header; undefined when no response arrived.
    console.log(error.statusCode, error.requestId);
  }
}

Retry guidance

  • 429 — honor Retry-After; otherwise calculate the delay from the second-based X-RateLimit-Reset. Add jitter only when coordinating many clients. Do not decide whether to send from X-RateLimit-Remaining — see Building a local limiter.
  • 502/503 — transient dependency failures. Retry with bounded exponential backoff; stop at your application's latency budget.
  • 404 — do not retry unchanged. Check the ID or pass provider explicitly when an identifier shape is ambiguous. A code of route_not_found means the path itself is wrong; market_not_found and event_not_found mean the identifier missed.
  • 405 — do not retry unchanged. Re-send with a method from the Allow header.
  • 409 — do not retry unchanged. The identifier is genuinely ambiguous, so the same request conflicts again. Re-send with ?provider= naming one of the candidates in the body, or with the composite {provider}:{id} form.
  • 400/401/402/403 — do not retry. Fix the request, plan, permission, or credentials.

On this page