Reference
Error Handling
Every error answers JSON with success: false and a human-readable message, and almost every one adds a stable machine-readable name for the failure. Branch on that name and the status, not on message, which may be reworded. The name is in code; on a few errors, marked † below, it is in errorCode instead, so read code and fall back to errorCode.
Error Response Format#
{ "success": false, "code": "AT_CAPACITY", "message": "Google SERP is at capacity. Retry in 60s — immediate retries will be refused.", "retryable": true, "retryAfterSec": 60}retryablesays whether the same request can succeed later. When it istrue, waitRetry-After(header) orretryAfterSec(body) before retrying.- Validation errors add an
errorsarray of{ field, message }:
{ "success": false, "code": "VALIDATION_ERROR", "message": "Query validation failed: \"q\" is required", "errors": [{ "field": "q", "message": "\"q\" is required" }]}- Insufficient-credit errors (
402) adddetails:
{ "success": false, "error": "Insufficient credits", "code": "INSUFFICIENT_CREDITS", "details": { "required": 5, "available": 2, "shortfall": 3 }, "message": "This request requires 5 credit(s), but you only have 2 credit(s) available. Please purchase more credits to continue.", "topUpUrl": "https://dashboard.searlo.tech/credits?tab=buy&source=api_402"}Codes#
Every status and code the API answers, in one table. A call that answers with one of them is never charged: it releases the credits it held, and the 429s for rate limits and searches in flight are refused before any credit is reserved. A 200 that finds nothing (an empty list) is not charged either. The one exception is a failed row inside a 200 POST /rankings/batch, which is billed with the batch (see Refunds). † marks a name carried in errorCode.
| Status | code | Meaning | Retry |
|---|---|---|---|
400 | VALIDATION_ERROR | A parameter is missing, malformed or out of range (errors lists which). Also a URL that cannot be fetched as written, and an image page past Google's last | Fix the request |
400 | INVALID_JSON | The body is not valid JSON | Fix the request |
400 | INVALID_IDEMPOTENCY_KEY | The idempotency key is longer than 255 characters | Fix the request |
400 | CITY_LOCATION_UNAVAILABLE | location or uule names a city or a state; only countries are applied (Location) | No |
400 | UNKNOWN_LOCATION | location is not a country we recognise; suggestions lists close matches | Fix the request |
400 | INVALID_DOMAIN | A rank check's domain cannot be parsed | Fix the request |
400 | PAGE_OUT_OF_RANGE † | Web, site and advanced search: the page starts past result 101. maxPage is the last page that can be served | No |
400 | DATE_FILTER_UNAVAILABLE † | A date filter (dateRestrict, dateRange) combined with search operators such as site: or filetype: | Remove the filter or the operators |
400 | INVALID_REQUEST † | Web data: the fetcher did not accept the URL; message says why | Fix the request |
400 / 404 | PLACE_NOT_FOUND † | Place details: the placeId names no business or q matched nothing (400), or Google does not know the id (404) | No |
401 | UNAUTHORIZED | A missing, invalid or expired API key, or a removed account | Fix the key |
402 | INSUFFICIENT_CREDITS | Not enough credits for this call. details gives required, available and shortfall, and topUpUrl links to buying more. On POST /extract, on crawl and batch-scrape starts and on async tasks the name is in errorCode † | After a top-up |
403 | FORBIDDEN | The account is suspended | No |
403 | FEATURE_NOT_AVAILABLE | The endpoint is switched off on this deployment; retryable: false | No |
403 | API_KEY_SCOPE_FORBIDDEN | An API key used on an account-management route (API keys, data export, account deletion, payments, billing); those need a dashboard login. GET /user/profile stays open to keys | No |
404 | NOT_FOUND | Unknown route or resource | No |
409 | IDEMPOTENCY_REQUEST_IN_PROGRESS | A request with this idempotency key is still running | Shortly |
413 | PAYLOAD_TOO_LARGE | The request body is too large | No |
422 | QUERY_NOT_SERVED | Full SERP and SERP HTML: a query shape these endpoints do not serve (search operators, e-mail lookups, long OR chains) | No |
422 | COUNTRY_UNAVAILABLE | Full SERP and SERP HTML: this gl cannot be served right now | Later |
422 | IDEMPOTENCY_KEY_REUSED | This idempotency key was already used for a different request | With a new key |
422 | TARGET_REFUSED † | Web data: the site refused the fetch; targetStatus is its own status | Only if retryable |
422 | TARGET_NOT_ALLOWED | Web data: a private or reserved network address | No |
422 | TARGET_UNREACHABLE † | Web data: no attempt could fetch the site (its secure-connection handshake is refused, it redirects in a loop, or the page is too large); targetError says which | No |
429 | 4201 | The account's rate limit | After Retry-After |
429 | 4202 | The API key's own rate limit | After Retry-After |
429 | 4203 | The account has too many searches in flight (errorCode CONCURRENCY_LIMIT; see Rate Limiting) | After Retry-After (1 second) |
429 | AT_CAPACITY | Full SERP, rank checks, AI answers, shopping: at capacity | After Retry-After |
429 | BUSY | SERP HTML: at capacity | After Retry-After |
429 | VERIFIER_BUSY | E-mail verifier: verification is paced; Retry-After is 30 seconds by default | After Retry-After |
500 | INTERNAL_ERROR | Something failed on our side | Yes, with backoff |
503 | UPSTREAM_UNAVAILABLE | The upstream (Google, an AI engine, or our own backend) did not give a usable answer; retryable: true | Yes, after Retry-After |
503 | UPSTREAM_RATE_LIMITED | Search is backing off from its upstream; retryAfter is the wait | Yes, after Retry-After |
503 | AT_CAPACITY | Web, image, news and the other search endpoints at capacity (a 429 on SERP and AI answers) | Yes, after Retry-After |
503 | WARMING | The search endpoint is starting up | Yes, after Retry-After |
503 | UPSTREAM_TIMEOUT | Our backend did not answer in time; retryable: true | Yes, after Retry-After |
503 | CREDIT_SYSTEM_UNAVAILABLE | Credit checks are briefly unavailable, so the call was refused before it ran; retryable: true | Yes, after Retry-After |
503 | REVIEWS_WITHHELD | Reviews: Google sent the place without its reviews; reviewCount is what it reports; retryable: true | Yes |
503 | SERVICE_UNAVAILABLE | The service cannot take the call right now; see retryable | If retryable |
503 | UPSTREAM_FAILED | A search failed and a retry will not help; retryable: false | No |
The rate-limit 429s carry a numeric code (4201, 4202, 4203); every other code is a string. Errors are JSON on every status, a 5xx included: we answer 503 rather than 502 or 504, which the network in front of us would replace with its own HTML page. Every retryable 503 carries Retry-After.
Idempotency keys#
Send X-Idempotency-Key (or Idempotency-Key), up to 255 characters, to make a retry of a paid call safe. Within an hour of the first use:
- the same key with the same request returns the stored response, marked
Idempotent-Replayed: true, without running or charging again; - the same key with a different request is
422IDEMPOTENCY_KEY_REUSED, not charged; - the same key while the first request is still running is
409IDEMPOTENCY_REQUEST_IN_PROGRESS: retry shortly (Retry-After: 2).
A request that failed is not kept, so you can retry it with the same key. A key longer than 255 characters is 400 INVALID_IDEMPOTENCY_KEY. Async tasks take their own idempotencyKey in the body (Async Tasks).
Refunds#
A call that fails is not charged: any response with status 400 or above, or with success: false, releases the credits it reserved. An answer with no results (an empty organic, news, videos, images, places, reviews or shopping list) is not charged either, and the body's credits always equals the X-Credits-Deducted header. In POST /search/batch and POST /serp/batch a failed or empty row costs nothing; a row inside a 200 POST /rankings/batch is billed like the rest of that batch (read each row's success and retryable). A failed async task is refunded.
Retrying#
Retry only what says it can succeed later: 429s, and 5xx responses with retryable: true. Wait Retry-After when it is present, and back off otherwise (for example 2, 5, then 10 seconds). Because failures are refunded, a retry never bills twice.
Examples
// Retries what says it can succeed later, waiting Retry-After when given.// Failed calls are refunded, so a retry never bills twice.async function searlo(path, params = {}, { retries = 3 } = {}) { const url = new URL(`https://api.searlo.tech/api/v1${path}`); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); for (let attempt = 0; ; attempt++) { const res = await fetch(url, { headers: { "x-api-key": process.env.SEARLO_API_KEY } }); const body = await res.json(); if (res.ok && body.success !== false) return body; const retryable = res.status === 429 || (res.status >= 500 && body.retryable !== false); if (!retryable || attempt >= retries) { const err = new Error(body.message || `HTTP ${res.status}`); err.status = res.status; err.code = body.code; // e.g. CITY_LOCATION_UNAVAILABLE, INSUFFICIENT_CREDITS throw err; } const wait = Number(res.headers.get("retry-after")) || [2, 5, 10][attempt] || 10; await new Promise(r => setTimeout(r, wait * 1000)); }}import osimport timeimport requests class SearloError(Exception): def __init__(self, status, code, message): super().__init__(message) self.status, self.code = status, code def searlo(path, params=None, retries=3): url = f"https://api.searlo.tech/api/v1{path}" headers = {"x-api-key": os.environ["SEARLO_API_KEY"]} for attempt in range(retries + 1): r = requests.get(url, params=params, headers=headers, timeout=120) body = r.json() if r.ok and body.get("success") is not False: return body retryable = r.status_code == 429 or (r.status_code >= 500 and body.get("retryable") is not False) if not retryable or attempt == retries: raise SearloError(r.status_code, body.get("code"), body.get("message", "request failed")) time.sleep(float(r.headers.get("Retry-After") or [2, 5, 10][min(attempt, 2)]))Stuck? Write to [email protected], or check live status.