# Batch requests

> Queue up to 100 requests per call with POST /v1/batches at half the credits. Poll GET /v1/batches/{id}, wait with the SDKs, or receive a signed webhook.

When you don't need the answer while you wait (rank tracking, nightly enrichment, dataset building), send the work to the batch lane. `POST /v1/batches` queues up to 100 requests for one endpoint and answers right away. Each request becomes its own job, costs **half** the realtime price, and is delivered by polling or by webhook.

| | Realtime (`POST /v1/search`, …) | Batch (`POST /v1/batches`) |
| --- | --- | --- |
| Requests per call | 1 | 1 to 100, all for one endpoint |
| Response | `200` with the result | `202` with one job per request |
| Latency | Seconds | Target: done within 15 minutes |
| Price | Normal price | **0.5×** the realtime price |
| Delivery | In the response | Poll `GET /v1/batches/{id}` or webhook |

Realtime endpoints take one JSON object per call. A JSON array body is rejected with `400 invalid_request`; to run many queries, either send them in parallel within your [rate limit](https://serpkite.com/docs/rate-limits) or queue them here.

## Create a batch

Send the endpoint name, the request bodies, and optionally a `webhook_url`:

cURL:

```bash
curl https://api.serpkite.com/v1/batches \
  -H "Authorization: Bearer $SERPKITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint":"search","requests":[{"q":"best espresso machine","country":"us"},{"q":"best espresso machine","country":"de","language":"de"}],"webhook_url":"https://example.com/hooks/serpkite"}'

# then poll a job (free)
curl https://api.serpkite.com/v1/batches/0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90 \
  -H "Authorization: Bearer $SERPKITE_API_KEY"
```

TypeScript:

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

const sk = new SerpKite();
const { batches } = await sk.batches.create({
  endpoint: "search",
  requests: [
    { q: "best espresso machine", country: "us" },
    { q: "best espresso machine", country: "de", language: "de" },
  ],
});
const done = await sk.batches.wait(batches[0].id); // polls until done or failed
console.log(done.status, done.result?.results[0].title);
```

Python:

```python
from serpkite import SerpKite

sk = SerpKite()
job = sk.batches.create(
    endpoint="search",
    requests=[
        {"q": "best espresso machine", "country": "us"},
        {"q": "best espresso machine", "country": "de", "language": "de"},
    ],
)
done = sk.batches.wait(job.batches[0].id)  # polls until done or failed
print(done.status, done.credits_used)
```

Go:

```go
c := serpkite.NewClient()
job, err := c.Batches.Create(ctx, serpkite.BatchCreateParams{
	Endpoint: serpkite.EndpointSearch,
	Requests: []any{
		serpkite.SearchParams{Q: "best espresso machine", Country: "us"},
		serpkite.SearchParams{Q: "best espresso machine", Country: "de", Language: "de"},
	},
})
if err != nil {
	log.Fatal(err)
}
done, err := c.Batches.Wait(ctx, job.Queued()[0].ID)
if err != nil {
	log.Fatal(err)
}
fmt.Println(done.Status, done.CreditsUsed)
```

| Field | Type | Description |
| --- | --- | --- |
| `endpoint` **required** | string | One of `search`, `images`, `videos`, `news`, `maps`, `places`, `reviews`, `shopping`, `scholar`, `patents`, `autocomplete`, `lens`, `webpage`. |
| `requests` **required** | array | 1 to 100 request objects. Each takes the same fields as the realtime endpoint, including `num: 100`, `format`, `fields`, `include_content` and `max_age`. |
| `webhook_url` | string | HTTPS URL that receives each finished job. Defaults to the account webhook set in the dashboard. See [Webhooks](https://serpkite.com/docs/webhooks). |

The response is `202 Accepted` with a `batches` array: one entry per request, in the same order. An entry is either a job or, when that request was invalid or could not be queued, an error object. One invalid request doesn't reject the rest.

```json
{
  "batches": [
    {
      "id": "0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90",
      "status": "queued",
      "endpoint": "/v1/search",
      "created_at": "2026-09-29T08:00:00Z",
      "completed_at": null,
      "credits_used": 0,
      "poll_url": "https://api.serpkite.com/v1/batches/0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90",
      "webhook_url": "https://example.com/hooks/serpkite",
      "webhook_status": "pending"
    },
    {
      "error": {
        "code": "invalid_request",
        "message": "unknown parameter \"type\": use the endpoint path",
        "request_id": "req_01J8ZK4M6Q2V7:1"
      }
    }
  ]
}
```

Requests are validated with the same [strict rules](https://serpkite.com/docs/parameters) as realtime calls: an unknown or renamed parameter fails that entry with a message naming the replacement.

### Safe retries with `Idempotency-Key`

A timeout on `POST /v1/batches` doesn't tell you whether the jobs were queued. Send an `Idempotency-Key` header (1 to 255 printable ASCII characters, for example a UUID you store with the batch) and retry with the same key and the same body: for 24 hours SerpKite returns the first response, with `Idempotent-Replayed: true`, instead of queueing and billing the jobs again.

```bash
curl https://api.serpkite.com/v1/batches \
  -H "Authorization: Bearer $SERPKITE_API_KEY" \
  -H "Idempotency-Key: 5f0c7d52-8a8e-4f7e-9d7b-1b6f0f3c2a91" \
  -H "Content-Type: application/json" \
  -d '{"endpoint":"search","requests":[{"q":"espresso grinder"}]}'
```

The same key with a different body gets `422 idempotency_key_reused`; a retry that arrives while the first call is still running gets `409` (retry shortly). The SDKs take the key as an option (`idempotencyKey`, `idempotency_key`, `serpkite.WithIdempotencyKey`) and, when it is set, also retry server errors and dropped connections.

## Pricing

Batch jobs cost half the realtime price, rounded up to the next 0.001 credit:

| Request | Realtime | Batch |
| --- | --- | --- |
| `search` (1 page) | 1 | 0.5 |
| `search` with `num: 100` | 7 | 3.5 |
| `search` with `include_content: 3` | 4 | 2 |

The maximum cost of each job is reserved from your balance when it is queued, so a queued job can't fail later for lack of credits. The jobs of one call are reserved together: if your balance, spend cap, key limit or free daily limit can't cover all of them, none is queued and each valid entry carries the error (for example `insufficient_credits`). If a job fails, or costs less than reserved (for example fewer pages came back), the difference is refunded. Queuing is checked against your [key limit and spend cap](https://serpkite.com/docs/spend-controls) just like a realtime call. The `POST /v1/batches` call itself counts once against your per-second [rate limit](https://serpkite.com/docs/rate-limits), whatever its size; the jobs are paced by the batch lane.

## Get the results

### Wait with an SDK

The SDKs poll for you: `sk.batches.wait(id)` in TypeScript, `sk.batches.wait(id)` in Python and `c.Batches.Wait(ctx, id)` in Go return the job once it is `done` or `failed`. See [SDKs](https://serpkite.com/docs/sdks).

### Poll

Poll [`GET /v1/batches/{id}`](https://serpkite.com/docs/endpoints/batches) (or the job's `poll_url`) until `status` is `done` or `failed`. Polling is free.

| `status` | Meaning |
| --- | --- |
| `queued` | Waiting for a worker. |
| `running` | Being fetched. |
| `done` | Finished; `result` holds the same body the realtime endpoint returns (`request`, `results`, `meta`, …). |
| `failed` | Could not be completed; `error` has `code` and `message`, and the credits were refunded. |

```bash
curl https://api.serpkite.com/v1/batches/0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90 \
  -H "Authorization: Bearer $SERPKITE_API_KEY"
```

```json
{
  "id": "0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90",
  "status": "done",
  "endpoint": "/v1/search",
  "created_at": "2026-09-29T08:00:00Z",
  "completed_at": "2026-09-29T08:03:12Z",
  "credits_used": 0.5,
  "poll_url": "https://api.serpkite.com/v1/batches/0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90",
  "webhook_url": null,
  "webhook_status": null,
  "error": null,
  "result": {
    "request": { "endpoint": "search", "engine": "google", "q": "best espresso machine", "country": "us" },
    "results": [{ "position": 1, "title": "…", "link": "https://www.example.com/…", "domain": "example.com" }],
    "related_searches": [],
    "meta": { "request_id": "req_01J8ZK4M6Q2V7", "credits_used": 0.5, "cached": false }
  }
}
```

Poll every few seconds at most; jobs don't finish faster if you poll harder. For large volumes, prefer webhooks.

### Webhooks

Set `webhook_url` on the batch (or a default webhook URL in the dashboard under **Settings**) and SerpKite POSTs each finished job to it, always signed with your account's webhook secret. Then you don't need to poll at all. Delivery format, signature verification and retries are covered in [Webhooks](https://serpkite.com/docs/webhooks).

## Limits and retention

- 1 to 100 requests per `POST /v1/batches` call, all for the same endpoint.
- Up to 10,000 queued jobs per account. Beyond that, new batches get `429 rate_limited` until some finish.
- Jobs target completion within 15 minutes. That is a target for the lane, not a per-job guarantee. Workers take jobs round-robin across accounts, so a large backlog from one account doesn't hold up another's jobs.
- A job's `result` is kept for 24 hours after it finishes, then deleted. The stored request (which contains your query) is deleted as soon as the job completes.
- You can list recent jobs and their status in the dashboard.

Related: [Batches endpoint](https://serpkite.com/docs/endpoints/batches), [Webhooks](https://serpkite.com/docs/webhooks), [Rank tracking](https://serpkite.com/docs/guides/rank-tracking), [Credits and billing](https://serpkite.com/docs/credits-and-billing).