> Searlo API Docs — Async Tasks
> Source: https://searlo.tech/docs#async-tasks
> Full docs: https://searlo.tech/docs/full.md

# Async Tasks

Queue a request, get a task id back at once, and collect the result later by polling or by webhook. The task runs the same service the synchronous route uses.

### Endpoints

```
POST   /async/task
POST   /async/task/batch
GET    /async/task/{taskId}
GET    /async/status
DELETE /async/queue
```

**Credits:** the endpoint's price, deducted when the task is queued. Kept when the task completes; refunded when it fails or is cancelled. Reading tasks and status is free.

### Request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `endpoint` | string | Yes | What to run (see the table below) |
| `request` | object | No | The parameters for that endpoint. Must include `q` (`ai/…` tasks also accept `prompt`), except for `goto` |
| `webhook` | object | No | `{ "url": "https://…", "secret": "…" }`. `url` is required inside it; `secret` (max 256 chars) signs each delivery |
| `priority` | number | No | 1–100 (default: 50). Higher runs first |
| `idempotencyKey` | string | No | Max 128 chars. Sending the same key again returns the existing task (`duplicate: true`, HTTP `200`) without a new charge |

The field is `endpoint`, not `type`, and the webhook is `webhook: { url, secret }`, not `webhookUrl`. Unknown top-level fields are stripped without an error: a request that sends `webhookUrl` is queued with no webhook, and one that sends `type` instead of `endpoint` is rejected with `400`.

| `endpoint` | Credits | Runs |
|------------|---------|------|
| `serp` | 3 per page; from 2026-11-01, +5 when the AI Overview had to be completed by rendering the page | [Full SERP](#full-serp). `request` takes the `GET /serp` fields. A country `location` (or its `uule`) is applied as `gl`; a city or state fails the task at once with `CITY_LOCATION_UNAVAILABLE`, refunded |
| `news/serp` | 2 per page | `GET /search/news/serp`, with the same location rule |
| `rankings/check` | 3 per 10 results of `depth` | [Rank Checks](#rankings). Send `keyword` and `domain` as usual and repeat the keyword as `q`, which the queue requires |
| `ai/chatgpt`, `ai/copilot` | 8 | [AI Answers](#ai-answers) |
| `ai/perplexity` | 2 | [AI Answers](#ai-answers) |
| `ai/gemini` | 4 | [AI Answers](#ai-answers) |
| `web` | 1 | Web search. `request` goes to the search service as is: use `num` (1–10), `start`, `country` and `language`, not `limit`, `page`, `gl` and `hl` |
| `maps` | 1 | Maps search. Use `num` for the result count; `gl`, `hl`, `ll`, `lat`, `lng` and `zoom` work as on `/search/maps` |
| `shopping` | 1 | Shopping search. Use `num` for the result count, plus `gl` and `hl` |
| `goto` | 0 | Resolves Google `/goto` redirect links: `url`, or `urls` (up to 50) |

The validator also accepts `keywords/…`, `domain/…`, `ai/aimode` and `ai/grok`; those endpoints are switched off in production, so do not queue them.

### Response

`POST /async/task` answers `202` with the queued task:

```json
{
  "success": true,
  "duplicate": false,
  "task": {
    "taskId": "5f0c2b1e-7a3d-4c1b-9d0e-2a6f8b9c1d3e",
    "endpoint": "serp",
    "status": "QUEUED",
    "priority": 50,
    "attempts": 0,
    "creditsHeld": 6,
    "creditsUsed": 0,
    "creditsRefunded": false,
    "request": { "q": "best running shoes", "gl": "us", "pages": 2 },
    "webhook": { "url": "https://example.com/hooks/searlo", "attempts": 0, "lastStatus": null, "deliveredAt": null },
    "queuedAt": "2026-09-29T10:00:00.000Z",
    "startedAt": null,
    "completedAt": null,
    "durationMs": null,
    "expiresAt": "2026-10-06T10:00:00.000Z"
  }
}
```

`GET /async/task/{taskId}` returns `{ "success": true, "task": { … } }` in the same shape, or `404` for an unknown id. `status` is `QUEUED`, `PROCESSING`, `COMPLETED`, `FAILED` or `CANCELLED`.

- `result` appears once the task is `COMPLETED`. For `serp`, `news/serp` and `ai/…` it is the body the synchronous route returns; for `rankings/check` it is the check's `result` object with `success: true`.
- `error` (`code`, `message`, `retryable`) appears when the task is `FAILED`.
- A retryable failure (capacity, an unusable upstream page) is queued again after a delay, the engine's `Retry-After` when it gave one, up to a configurable number of attempts. After that, or on a non-retryable error, the task is `FAILED` and its credits are refunded (`creditsRefunded: true`). Errors in the request itself (`VALIDATION_ERROR`, `CITY_LOCATION_UNAVAILABLE`, `UNKNOWN_LOCATION`, `INVALID_DOMAIN`, a query shape Google refuses on sight) fail at once, without retries.
- A result larger than a configurable size has its bulkiest top-level fields removed, listed in `result._trimmed`.
- Tasks are deleted after a configurable retention period; `expiresAt` says when.

### Batch

`POST /async/task/batch` takes `{ "tasks": [ … ] }` with 1–500 task objects, each shaped like a single request. Each task is charged when it is queued. The response is `202` when at least one task was accepted, `400` otherwise:

```json
{
  "success": true,
  "accepted": 2,
  "total": 3,
  "tasks": [
    { "accepted": true, "duplicate": false, "taskId": "5f0c2b1e-7a3d-4c1b-9d0e-2a6f8b9c1d3e", "status": "QUEUED", "creditsHeld": 8 },
    { "accepted": true, "duplicate": false, "taskId": "9b1d7e0a-2c4f-4e8b-a6d3-1f2e3d4c5b6a", "status": "QUEUED", "creditsHeld": 2 },
    { "accepted": false, "error": "request.q is required", "endpoint": "ai/copilot" }
  ]
}
```

If your credits run out part-way, the batch stops there: the remaining tasks are not queued and do not appear in `tasks`, so compare `tasks.length` with `total`. A task that fails validation (an unknown `endpoint`, say) rejects the whole batch with `400`.

### Queue Status

`GET /async/status` returns your queue: `queued`, `processing`, `priorityBreakdown` (`[{ "priority": 50, "count": 12 }]`), `last24h` (`completed`, `failed`) and `worker` (the answering process's `inflight` and `concurrency` per lane). `DELETE /async/queue` cancels every task still `QUEUED` and refunds it: `{ "success": true, "cleared": 12, "creditsRefunded": 36 }`.

### Webhooks

When a task ends as `COMPLETED` or `FAILED` (not when it is queued again for a retry), Searlo POSTs to `webhook.url`:

```json
{
  "taskId": "5f0c2b1e-7a3d-4c1b-9d0e-2a6f8b9c1d3e",
  "endpoint": "serp",
  "status": "COMPLETED",
  "result": { "success": true, "organic": [], "peopleAlsoAsk": [] },
  "creditsUsed": 6,
  "completedAt": "2026-09-29T10:01:12.000Z"
}
```

A failed task sends `error` instead of `result`. Headers:

- `X-Searlo-Task-Id`, and `X-Searlo-Event` (`task.completed` or `task.failed`)
- `X-Searlo-Timestamp`: milliseconds since the epoch
- `X-Searlo-Signature` (when you set a secret): `v1=` followed by the hex HMAC-SHA256, keyed with your secret, of `<timestamp>.<raw body>`

Any `2xx` counts as delivered; redirects are not followed. A failed delivery (another status, or no answer within a configurable timeout) is retried after 5 minutes, 30 minutes, 2 hours and 12 hours, then given up. The URL must be HTTPS and resolve to a public address; it is checked when you queue the task and again before each delivery.

## Code Examples

### cURL

```bash
# Queue a two-page SERP with a signed webhook (6 credits held)
curl -X POST "https://api.searlo.tech/api/v1/async/task" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "serp",
    "request": { "q": "best running shoes", "gl": "us", "pages": 2 },
    "webhook": { "url": "https://example.com/hooks/searlo", "secret": "whsec_change_me" },
    "idempotencyKey": "serp-best-running-shoes-2026-09-29"
  }'

# Poll it
curl "https://api.searlo.tech/api/v1/async/task/5f0c2b1e-7a3d-4c1b-9d0e-2a6f8b9c1d3e" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
import crypto from "node:crypto";

// Queue one AI answer per engine in a single call
const res = await fetch("https://api.searlo.tech/api/v1/async/task/batch", {
  method: "POST",
  headers: { "x-api-key": process.env.SEARLO_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    tasks: ["chatgpt", "perplexity", "copilot"].map(engine => ({
      endpoint: `ai/${engine}`,
      request: { q: "best crm for startups", gl: "us" },
      webhook: { url: "https://example.com/hooks/searlo", secret: process.env.SEARLO_WEBHOOK_SECRET },
    })),
  }),
});
const { accepted, total, tasks } = await res.json();
console.log(`${accepted}/${total} queued`, tasks.map(t => t.taskId));

// In your webhook handler: verify the signature against the RAW request body
function verifySearloSignature(rawBody, headers, secret) {
  const timestamp = headers["x-searlo-timestamp"];
  const expected =
    "v1=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const received = String(headers["x-searlo-signature"] || "");
  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
  );
}
```

### Python

```python
import os
import time
import requests

API = "https://api.searlo.tech/api/v1"
HEADERS = {"x-api-key": os.environ["SEARLO_API_KEY"]}

r = requests.post(
    f"{API}/async/task",
    json={
        "endpoint": "rankings/check",
        # rankings/check reads keyword and domain; the queue also requires q
        "request": {
            "q": "project management software",
            "keyword": "project management software",
            "domain": "example.com",
            "depth": 20,
        },
    },
    headers=HEADERS,
)
r.raise_for_status()
task_id = r.json()["task"]["taskId"]

while True:
    task = requests.get(f"{API}/async/task/{task_id}", headers=HEADERS).json()["task"]
    if task["status"] in ("COMPLETED", "FAILED", "CANCELLED"):
        break
    time.sleep(5)

print(task["status"], task.get("result") or task.get("error"), task["creditsUsed"])
```


---
*From https://searlo.tech/docs — see full docs at https://searlo.tech/docs/full.md*