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#

JSON
{  "success": false,  "code": "AT_CAPACITY",  "message": "Google SERP is at capacity. Retry in 60s — immediate retries will be refused.",  "retryable": true,  "retryAfterSec": 60}
  • retryable says whether the same request can succeed later. When it is true, wait Retry-After (header) or retryAfterSec (body) before retrying.
  • Validation errors add an errors array of { field, message }:
JSON
{  "success": false,  "code": "VALIDATION_ERROR",  "message": "Query validation failed: \"q\" is required",  "errors": [{ "field": "q", "message": "\"q\" is required" }]}
  • Insufficient-credit errors (402) add details:
JSON
{  "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.

StatuscodeMeaningRetry
400VALIDATION_ERRORA 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 lastFix the request
400INVALID_JSONThe body is not valid JSONFix the request
400INVALID_IDEMPOTENCY_KEYThe idempotency key is longer than 255 charactersFix the request
400CITY_LOCATION_UNAVAILABLElocation or uule names a city or a state; only countries are applied (Location)No
400UNKNOWN_LOCATIONlocation is not a country we recognise; suggestions lists close matchesFix the request
400INVALID_DOMAINA rank check's domain cannot be parsedFix the request
400PAGE_OUT_OF_RANGE †Web, site and advanced search: the page starts past result 101. maxPage is the last page that can be servedNo
400DATE_FILTER_UNAVAILABLE †A date filter (dateRestrict, dateRange) combined with search operators such as site: or filetype:Remove the filter or the operators
400INVALID_REQUEST †Web data: the fetcher did not accept the URL; message says whyFix the request
400 / 404PLACE_NOT_FOUND †Place details: the placeId names no business or q matched nothing (400), or Google does not know the id (404)No
401UNAUTHORIZEDA missing, invalid or expired API key, or a removed accountFix the key
402INSUFFICIENT_CREDITSNot 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
403FORBIDDENThe account is suspendedNo
403FEATURE_NOT_AVAILABLEThe endpoint is switched off on this deployment; retryable: falseNo
403API_KEY_SCOPE_FORBIDDENAn 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 keysNo
404NOT_FOUNDUnknown route or resourceNo
409IDEMPOTENCY_REQUEST_IN_PROGRESSA request with this idempotency key is still runningShortly
413PAYLOAD_TOO_LARGEThe request body is too largeNo
422QUERY_NOT_SERVEDFull SERP and SERP HTML: a query shape these endpoints do not serve (search operators, e-mail lookups, long OR chains)No
422COUNTRY_UNAVAILABLEFull SERP and SERP HTML: this gl cannot be served right nowLater
422IDEMPOTENCY_KEY_REUSEDThis idempotency key was already used for a different requestWith a new key
422TARGET_REFUSED †Web data: the site refused the fetch; targetStatus is its own statusOnly if retryable
422TARGET_NOT_ALLOWEDWeb data: a private or reserved network addressNo
422TARGET_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 whichNo
4294201The account's rate limitAfter Retry-After
4294202The API key's own rate limitAfter Retry-After
4294203The account has too many searches in flight (errorCode CONCURRENCY_LIMIT; see Rate Limiting)After Retry-After (1 second)
429AT_CAPACITYFull SERP, rank checks, AI answers, shopping: at capacityAfter Retry-After
429BUSYSERP HTML: at capacityAfter Retry-After
429VERIFIER_BUSYE-mail verifier: verification is paced; Retry-After is 30 seconds by defaultAfter Retry-After
500INTERNAL_ERRORSomething failed on our sideYes, with backoff
503UPSTREAM_UNAVAILABLEThe upstream (Google, an AI engine, or our own backend) did not give a usable answer; retryable: trueYes, after Retry-After
503UPSTREAM_RATE_LIMITEDSearch is backing off from its upstream; retryAfter is the waitYes, after Retry-After
503AT_CAPACITYWeb, image, news and the other search endpoints at capacity (a 429 on SERP and AI answers)Yes, after Retry-After
503WARMINGThe search endpoint is starting upYes, after Retry-After
503UPSTREAM_TIMEOUTOur backend did not answer in time; retryable: trueYes, after Retry-After
503CREDIT_SYSTEM_UNAVAILABLECredit checks are briefly unavailable, so the call was refused before it ran; retryable: trueYes, after Retry-After
503REVIEWS_WITHHELDReviews: Google sent the place without its reviews; reviewCount is what it reports; retryable: trueYes
503SERVICE_UNAVAILABLEThe service cannot take the call right now; see retryableIf retryable
503UPSTREAM_FAILEDA search failed and a retry will not help; retryable: falseNo

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 422 IDEMPOTENCY_KEY_REUSED, not charged;
  • the same key while the first request is still running is 409 IDEMPOTENCY_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.