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"
}codeis a stable machine-readable reason. Branch on it: a code is never renamed or reused, and new ones may be added.messagesays 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_coderepeats the HTTP status, for logs that keep only the body.paramnames the query or path parameter the error concerns, when there is one.errorslists every problem when a request has more than one, such as a lookup batch with several bad identifiers.messagedescribes 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-Idyou 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
| Status | code | Meaning |
|---|---|---|
400 | unknown_parameter | The endpoint does not read that query parameter. message lists the ones it accepts |
400 | invalid_parameter | A value is malformed, empty, out of range, sent more than once, or contradicts another parameter (e.g. a source_id without its {provider}: prefix) |
400 | missing_parameter | A required parameter is absent |
400 | invalid_cursor | A cursor that is malformed, stale, or from another endpoint or filter set. Start again without it |
400 | invalid_identifier | A market_id or event_id that cannot exist on the provider it resolves to, or whose {provider}: prefix names no provider |
400 | upstream_rejected | The venue rejected the request |
401 | missing_api_key | No Authorization: Bearer <key> header. message says what was sent instead, if anything |
401 | invalid_api_key | The key is not valid, including any key longer than 256 characters |
402 | payment_required | Free monthly allowance is exhausted, or a paid subscription is in a payment-recovery state — see Plans & Limits |
403 | forbidden | The key lacks the permission this endpoint requires |
404 | route_not_found | No route matches the path. Check the path before you check the ID |
404 | market_not_found | The market does not exist on the selected or inferred provider |
404 | event_not_found | The event does not exist on the selected or inferred provider |
404 | profile_not_found | No Polymarket profile matches the address or username |
405 | method_not_allowed | The path exists but not under the HTTP method you sent — read the Allow header |
409 | ambiguous_identifier | A bare market_id or event_id resolves on more than one provider, so no single one can honestly be returned — see Events & Markets |
429 | rate_limited | The key exceeded its per-minute limit. Honour Retry-After |
429 | usage_limit_exceeded | The key used up an allowance that does not refill within the minute |
429 | upstream_rate_limited | The venue rate-limited the request |
502 | upstream_unavailable | PredictorSDK could not get a valid response from a venue or from API-key verification |
503 | service_unavailable | A 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
404with coderoute_not_found. An endpoint's own404(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
405with codemethod_not_allowed, anAllowheader naming the methods that path does accept, and the same list inmessage. Every matching/markets/events reference endpoint here is aGET(POST /v1/account/billing/portalis the documented billing exception). - A request carrying a query parameter the endpoint does not read returns
400with codeunknown_parameter. One that sends a parameter twice (other than the repeatableevent_idandsource_idof the matching lookup), or a filter or identifier with an empty value, returns400with codeinvalid_parameter. - A query string that is not
&-separatedkey=valuepairs returns400with codeinvalid_parameter, quoting the pair at fault: a;used as a separator, or a%not followed by two hex digits. Percent-encode a literal;as%3Band%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:
| Header | Unit | Meaning |
|---|---|---|
X-RateLimit-Limit | requests | Requests allowed in a 60-second window. Equals your plan's rate_limit_per_min from GET /v1/plans |
X-RateLimit-Remaining | requests | Advisory estimate. Not a countdown — see below |
X-RateLimit-Reset | Unix seconds | End of the current window. A step function: the same value on every response inside one window |
Retry-After | seconds | Whole 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-Resetis not a refill instant. Nothing jumps back toX-RateLimit-Limitwhen 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 a429can last.- Consuming exactly
X-RateLimit-Limitrequests and then waiting forX-RateLimit-Resetis 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:
- Read
rate_limit_per_minfor your plan fromGET /v1/plans, or takeX-RateLimit-Limitfrom any authenticated response. They are the same number. - 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 / limitseconds apart, which is the same thing without the bookkeeping. - Back off whenever a
429arrives, honouringRetry-After. The429andRetry-Afterare authoritative; they are computed from the limiter's own window instant and never disagree withX-RateLimit-Reset.
402 Payment Required response
The response body includes an action field so clients can render the right follow-up:
action | Meaning | Recommended UX |
|---|---|---|
upgrade_plan | The 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_payment | The 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-basedX-RateLimit-Reset. Add jitter only when coordinating many clients. Do not decide whether to send fromX-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
providerexplicitly when an identifier shape is ambiguous. Acodeofroute_not_foundmeans the path itself is wrong;market_not_foundandevent_not_foundmean the identifier missed. - 405 — do not retry unchanged. Re-send with a method from the
Allowheader. - 409 — do not retry unchanged. The identifier is genuinely ambiguous, so
the same request conflicts again. Re-send with
?provider=naming one of thecandidatesin the body, or with the composite{provider}:{id}form. - 400/401/402/403 — do not retry. Fix the request, plan, permission, or credentials.