# Errors

> Every SerpKite error uses the same JSON body with a stable code, a human-readable message and a request ID. Errors are never billed. Here is every code and what to do about it.

## Error shape

Whenever a call fails, the HTTP status is `4xx` or `5xx` and the body looks like this:

402 Payment Required:

```json
{
  "error": {
    "code": "insufficient_credits",
    "message": "Your balance is 0 credits. Buy a pack at https://app.serpkite.com/billing.",
    "request_id": "req_01J8ZK7T2RX4B"
  }
}
```

| Field | Meaning |
| --- | --- |
| `error.code` | Stable, machine-readable code. Branch on this, not on the message. |
| `error.message` | Human-readable explanation. It may change wording over time. |
| `error.request_id` | The same value as the `X-Request-Id` header. Quote it when you contact support. |

The shape is the same on every endpoint, including the [Custom Search–compatible endpoint](https://serpkite.com/docs/endpoints/customsearch) (which does not imitate Google's error format) and the dashboard API.

**Errors are never billed.** A failed call has `X-Credits-Used: 0`, and any credits reserved for it are refunded. See [Credits and billing](https://serpkite.com/docs/credits-and-billing#what-is-never-billed).

## All error codes

| Status | Code | Retry? | Meaning |
| --- | --- | --- | --- |
| 400 | `invalid_request` | No | A parameter is missing or invalid. |
| 401 | `unauthorized` | No | The API key is missing, invalid or revoked. |
| 402 | `insufficient_credits` | No | Your balance is too low. Buy a pack or wait for the monthly free grant. |
| 403 | `key_limit_reached` | No | This key hit its monthly credit limit. |
| 403 | `spend_cap_reached` | No | The account's monthly spend cap is reached. Raise it in the dashboard or wait for the next UTC month. |
| 404 | `not_found` | No | The batch job does not exist, belongs to another account, or its result has expired. |
| 429 | `rate_limited` | Yes, with backoff | Too many requests per second for your plan. Retry after the Retry-After header. |
| 429 | `daily_limit_reached` | No | Free-tier daily limit reached (when one is configured). Buying any pack removes it. |
| 500 | `internal` | Yes, with backoff | Unexpected server error. Not billed; retry with backoff and quote the request_id if it persists. |
| 503 | `upstream_error` | Yes, with backoff | Google could not be fetched or parsed. Not billed; retry after Retry-After. |
| 503 | `upstream_blocked` | Yes, with backoff | Google is rate limiting the upstream right now. Not billed; retry after Retry-After. |
| 503 | `upstream_timeout` | Yes, with backoff | The search took too long. Not billed; retry after Retry-After. |
| 503 | `unavailable` | Yes, with backoff | The endpoint is temporarily unavailable. Not billed; retry with backoff. |

## What to do

### Fix the request (400)

`invalid_request` means a parameter is missing, unknown, out of range or of the wrong type. The message names the parameter, for example `page must be between 1 and 10`, `num > 10 (depth) requires page=1` or `include_content is only supported on /v1/search`. Retrying the same request will fail the same way. Check the [parameter reference](https://serpkite.com/docs/parameters) and the endpoint's page.

Unknown parameters are rejected rather than ignored, and when the name is one other SERP APIs use, the message tells you the replacement:

400 Bad Request:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "unknown parameter \"gl\": use country",
    "request_id": "req_01J8ZK9W3HC2D"
  }
}
```

A JSON array body is also a `400`: send one object per call, or queue many with [`POST /v1/batches`](https://serpkite.com/docs/batch). See [Strict validation](https://serpkite.com/docs/parameters#strict-validation).

### Fix the key (401)

`unauthorized` means the key is missing, malformed or revoked. The message tells you which. Check that you send `Authorization: Bearer skt_live_…` (or `?api_key=` on a `GET`), and that the key still exists under **API keys** in the dashboard. See [Authentication](https://serpkite.com/docs/authentication).

### Add credits or raise a limit (402, 403)

- `insufficient_credits` (402): the balance can't cover the call. [Buy a pack](https://app.serpkite.com/billing) or wait for the monthly free grant. Turn on [low-balance alerts or auto-recharge](https://serpkite.com/docs/spend-controls) so this doesn't surprise you in production.
- `key_limit_reached` (403): this key used its monthly credit limit. Raise or clear the limit on the key, or use another key. Limits reset at the start of the UTC calendar month. See [API keys](https://serpkite.com/docs/api-keys#per-key-monthly-limit).
- `spend_cap_reached` (403): the whole account hit its monthly spend cap. Raise it under **Settings** or wait for the next UTC month. See [Spend controls](https://serpkite.com/docs/spend-controls).

### Slow down (429)

- `rate_limited`: too many requests per second for this key, or too many batch jobs queued. Wait for `Retry-After` seconds and retry with backoff. See [Rate limits](https://serpkite.com/docs/rate-limits).
- `daily_limit_reached`: a free account used its daily allowance. Retrying today won't help; buying any pack removes the cap.
- The no-login [playground](https://serpkite.com/playground) has its own per-IP limits (5 searches per minute, 20 per day) and answers `429 rate_limited` when you pass them. API keys are not affected.

### Retry later (5xx)

These are on our side or Google's, are never billed, and are safe to retry with exponential backoff. Upstream failures come with a `Retry-After` header (a few seconds); wait at least that long:

- `upstream_error` (503): Google's page couldn't be fetched or parsed.
- `upstream_blocked` (503): Google is rate limiting our upstream at the moment.
- `upstream_timeout` (503): the search took too long. `include_content` and `num: 100` calls take longer, so give them a generous client timeout.
- `unavailable` (503): the endpoint is temporarily unavailable.
- `internal` (500): unexpected error. If it persists, send us the `request_id`.

`404 not_found` is only returned by [`GET /v1/batches/{id}`](https://serpkite.com/docs/endpoints/batches): the job ID is wrong, belongs to another account, or its result expired.

## Errors in batch requests

[`POST /v1/batches`](https://serpkite.com/docs/batch) returns `202` with one entry per request, in order. An entry that couldn't be queued (for example an invalid parameter) is an error object instead of a batch job:

```json
{
  "batches": [
    { "id": "0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90", "status": "queued", "endpoint": "/v1/search", "poll_url": "https://api.serpkite.com/v1/batches/0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90" },
    { "error": { "code": "invalid_request", "message": "q is required", "request_id": "req_01J8ZKB7M2:1" } }
  ]
}
```

Only queued jobs are billed. Check each entry for an `error` key rather than relying on the HTTP status. Errors that apply to the whole call (a bad key, a rate limit, more than 100 requests) come back as a single error object with the matching status. A job that fails later has `status: "failed"` and an `error` object when you poll it, and is refunded.

## Handling errors in code

The SDKs turn every error body into a typed exception with the same fields, and retry `429` and `5xx` for you (`maxRetries` / `max_retries` / `WithMaxRetries`):

TypeScript:

```ts
import { SerpKite, SerpKiteError } from "serpkite";

const sk = new SerpKite();
try {
  const res = await sk.search({ q: "best espresso machine 2026" });
  console.log(res.results.length);
} catch (err) {
  if (err instanceof SerpKiteError) {
    if (["insufficient_credits", "key_limit_reached", "spend_cap_reached"].includes(err.code)) {
      // alert a human: money or limits
    }
    throw new Error(`${err.status} ${err.code}: ${err.message} (${err.requestId})`);
  }
  throw err;
}
```

Python:

```python
from serpkite import SerpKite, SerpKiteError

sk = SerpKite()
try:
    res = sk.search("best espresso machine 2026")
except SerpKiteError as err:
    if err.code in ("insufficient_credits", "key_limit_reached", "spend_cap_reached"):
        ...  # alert a human: money or limits
    raise RuntimeError(f"{err.status} {err.code}: {err.message} ({err.request_id})")
```

Go:

```go
res, err := c.Search(ctx, serpkite.SearchParams{Q: "best espresso machine 2026"})
var apiErr *serpkite.Error
if errors.As(err, &apiErr) {
	switch apiErr.Code {
	case "insufficient_credits", "key_limit_reached", "spend_cap_reached":
		// alert a human: money or limits
	}
	return fmt.Errorf("%d %s: %s (%s)", apiErr.Status, apiErr.Code, apiErr.Message, apiErr.RequestID)
}
```

cURL:

```bash
curl -sS https://api.serpkite.com/v1/search \
  -H "Authorization: Bearer $SERPKITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"q":"best espresso machine 2026"}' | jq '.error // empty'
```

With a raw HTTP client, check the status code and branch on `error.code`: retry `rate_limited`, `upstream_error`, `upstream_blocked`, `upstream_timeout` and `unavailable` with backoff, and surface everything else.

A retry helper with backoff is in [Rate limits](https://serpkite.com/docs/rate-limits#retrying-well).

## Empty results are not errors

A query that Google answers with no results returns `200` with an empty `results` array and `meta.parse_quality: "empty"`, and it is not billed. `parse_quality: "partial"` means some parts of the page could not be parsed; the parts that were are returned. Neither is an error. See [Response headers](https://serpkite.com/docs/response-headers#body-meta).

## Getting help

Email support@serpkite.com with the `request_id` and the time of the request. We can find the request from its ID alone: we don't log query text, so the ID is how we trace a call. Service status is at [status.serpkite.com](https://status.serpkite.com).