# Background jobs

When a list is longer than one call can take, send it as a **job**: the API runs it in the background, you poll for the results (or get a signed webhook when it finishes), and you are charged only for what actually ran.

```bash
POST https://api.onekey.dev/v1/jobs
x-api-key: YOUR_API_KEY
content-type: application/json
```

`POST /v1/batch/jobs` is the same route under a second name.

## Submit a job

A job is either a **batch** (up to 5,000 items, each the same shape as an item of [`POST /v1/batch`](https://onekey.dev/docs/batch.md)) or a **crawl** (a list of URLs for one `web` endpoint, up to 5,000):

```bash
curl -X POST "https://api.onekey.dev/v1/jobs" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Idempotency-Key: 6f1c2d3e-4b5a-4c6d-8e7f-90a1b2c3d4e5" \
  -H "content-type: application/json" \
  -d '{
    "name": "weekly profiles",
    "requests": [
      {"platform": "github", "endpoint": "profile", "params": {"username": "torvalds"}},
      {"platform": "github", "endpoint": "profile", "params": {"username": "gvanrossum"}}
    ],
    "webhook": {"url": "https://hooks.example.com/jobs"}
  }'
```

```bash
curl -X POST "https://api.onekey.dev/v1/jobs" \
  -H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
  -d '{"crawl": {"endpoint": "article", "urls": ["https://example.org/a", "https://example.org/b"]}}'
```

The answer is `202` with the job. Send exactly one of `requests` or `crawl`; `name` (120 characters) and `webhook` are optional. A crawl runs the `web` endpoint you name (`article`, `markdown`, `metadata`, any `web` endpoint with a `url` parameter) once per distinct URL; it fetches exactly the URLs you list and does not follow links. AI parameters (`label`, `relevance`, `themes`, `include`) are not supported in jobs.

```json
{
  "success": true,
  "data": {
    "id": "job_9f1c...", "name": "weekly profiles", "kind": "batch", "status": "queued",
    "total": 2, "completed": 0, "succeeded": 0, "failed": 0, "progress_pct": 0.0, "cancel_requested": false,
    "credits": {"reserved": 4.0, "charged": 0.0, "refunded": 0.0},
    "webhook": {"url": "https://hooks.example.com/jobs", "secret": "whsec_..."},
    "created_at": "2026-10-02T10:00:00+00:00", "started_at": null, "finished_at": null, "expires_at": null, "error": null
  },
  "metadata": {"request_id": "...", "idempotent_replay": false}
}
```

`webhook.secret` is shown **once**, in this answer. Nothing is held and nothing runs if the request is refused.

## Credits: held up front, unused refunded

At submission the **worst case** for the whole job is held in one step: the sum of every item's price, or its dearest reachable source, the same upper bound a [batch](https://onekey.dev/docs/batch.md) is checked against. If the balance (or the key's [credit limit](https://onekey.dev/docs/key-limits.md)) cannot cover it, the job is refused with [`INSUFFICIENT_CREDITS`](https://onekey.dev/docs/errors.md#insufficient-credits) or [`KEY_BUDGET_EXCEEDED`](https://onekey.dev/docs/errors.md#key-budget-exceeded) and nothing is held.

- Each item then runs like a normal call: a [cache hit is free](https://onekey.dev/docs/caching.md), a failed, empty or not-found item is refunded, and it appears in the request log and the dashboard usage.
- When the job finishes (completed, cancelled or failed), whatever is still held is given back. `credits.refunded` is `reserved − charged`.
- `credits.charged` is what the items actually cost. The ledger shows the hold, the per-item releases and the refund (`job_hold`, `job_release`, `job_refund` in [`GET /v1/credits/transactions`](https://onekey.dev/docs/credits.md)).

## Read a job

```bash
curl "https://api.onekey.dev/v1/jobs/job_9f1c..." -H "x-api-key: YOUR_API_KEY"
curl "https://api.onekey.dev/v1/jobs/job_9f1c...?after=99&limit=100" -H "x-api-key: YOUR_API_KEY"
```

Free. The job object plus `results`, the items so far in order: `index`, `platform`, `endpoint`, `params`, `state` (`pending`, `running`, `ok`, `error`, `skipped`), `status` (the HTTP status the call would have had), `data`, `error` (same shape as everywhere, with `doc_url`), `credits_charged`, `cached`. `limit` is 0 to 500 (default 100, 0 returns no rows); `after` is the last `index` you have, and `next_after` is the value to send next (`null` on the last page). Poll while `status` is `queued` or `running`; `completed`, `cancelled` and `failed` are final.

A result larger than 256 KB is billed but not stored: the item has `data: null` and `data_omitted: true`. `GET /v1/jobs` lists the account's jobs, newest first (`limit` up to 100). Any key of the account can read any job of the account.

## Statuses

| `status` | Meaning |
| --- | --- |
| `queued` | Held and stored; no worker has picked it up yet. |
| `running` | A worker is running its items (up to 4 at a time). `completed` counts items that finished, `succeeded` and `failed` split them. |
| `completed` | Every item ran. Individual items may still be `error`: that is a result, not a failed job. |
| `cancelled` | You cancelled it. Items that had not started are `skipped` with `JOB_CANCELLED` and refunded. |
| `failed` | The key that created the job was revoked while it ran; the rest were skipped and refunded. |

A worker that dies mid-job loses its lease after two minutes and another worker resumes from the stored items; an item that was in flight runs again (usually a free cache hit).

## Cancel

`DELETE /v1/jobs/{id}` stops a job. Items that already ran keep their results and their charge; the rest are skipped and refunded. A job being run right now answers with `cancel_requested: true` and finishes cancelling within a batch of items. A finished job answers [`JOB_FINISHED`](https://onekey.dev/docs/errors.md#job-finished).

## Idempotency

Send an [`Idempotency-Key`](https://onekey.dev/docs/idempotency.md) (a UUID). The same key with the same body returns the **same job** (`200`, `idempotent_replay: true`, no secret, nothing held again). The same key with a different body is [`IDEMPOTENCY_KEY_REUSED`](https://onekey.dev/docs/errors.md#idempotency-key-reused). A key that is not a UUID is [`IDEMPOTENCY_KEY_INVALID`](https://onekey.dev/docs/errors.md#idempotency-key-invalid).

## Completion webhook

With `webhook.url` the API sends one signed `job.completed` event when the job reaches a final status. The URL rules, the signature (`Onekey-Signature: t=<unix>,v1=<hmac-sha256>` over the timestamp and the raw body, with the secret from the submit answer), the retry schedule (1 m, 5 m, 30 m, 2 h, 12 h; at most 6 attempts) and the redirect policy are those of [monitors](https://onekey.dev/docs/monitors.md#verify-the-signature); one verifier covers both. A URL that is not `https` on a public host is refused with [`WEBHOOK_URL_REJECTED`](https://onekey.dev/docs/errors.md#webhook-url-rejected) before anything is held.

```json
{
  "id": "dlv_3c1a...", "type": "job.completed", "created_at": "2026-10-02T10:04:00+00:00", "api_version": "v1",
  "job": {"id": "job_9f1c...", "status": "completed", "total": 2, "completed": 2, "succeeded": 2, "failed": 0,
          "credits": {"reserved": 4.0, "charged": 4.0, "refunded": 0.0}, "...": "..."},
  "results_url": "/v1/jobs/job_9f1c..."
}
```

Headers: `Onekey-Event: job.completed`, `Onekey-Job-Id`, `Onekey-Delivery-Id` (the same on every retry: de-duplicate on it) and `Onekey-Signature`. The event carries no results; fetch them from `results_url`. Deliveries and their attempts are on `GET /v1/jobs/{id}` under `webhook.deliveries`.

## Limits

| Limit | Value |
| --- | --- |
| Items per job | 5,000 ([`INVALID_JOB`](https://onekey.dev/docs/errors.md#invalid-job) beyond) |
| Request body | 2,000,000 bytes |
| Unfinished jobs | 10 per account and 3 per key ([`JOB_LIMIT_REACHED`](https://onekey.dev/docs/errors.md#job-limit-reached) beyond) |
| Items in flight per job | 4 |
| Results kept | 7 days after the job finishes, then deleted ([`JOB_NOT_FOUND`](https://onekey.dev/docs/errors.md#job-not-found)) |
| Stored result per item | 256 KB |

Submitting is one request for the [rate limit](https://onekey.dev/docs/rate-limits.md); items run inside the job and are not counted against it. The dashboard has the same routes under `/v1/dashboard/jobs` (session cookie).

## Errors

| Status | `error.type` | When |
| --- | --- | --- |
| 402 | [`INSUFFICIENT_CREDITS`](https://onekey.dev/docs/errors.md#insufficient-credits) / [`KEY_BUDGET_EXCEEDED`](https://onekey.dev/docs/errors.md#key-budget-exceeded) | The worst case does not fit. Nothing was held. |
| 404 | [`JOB_NOT_FOUND`](https://onekey.dev/docs/errors.md#job-not-found) | Unknown id, another account's job, or results past their 7 days. |
| 409 | [`JOB_LIMIT_REACHED`](https://onekey.dev/docs/errors.md#job-limit-reached) | Too many unfinished jobs. |
| 409 | [`JOB_FINISHED`](https://onekey.dev/docs/errors.md#job-finished) | Cancelling a finished job. |
| 422 | [`INVALID_JOB`](https://onekey.dev/docs/errors.md#invalid-job) | Malformed body, both or neither of `requests` and `crawl`, an unknown field, too many items, an AI parameter. |
| 422 | [`WEBHOOK_URL_REJECTED`](https://onekey.dev/docs/errors.md#webhook-url-rejected) | The webhook URL failed the egress rules. |

## Related

- [Batch requests](https://onekey.dev/docs/batch.md): the synchronous form for up to 100 items.
- [Monitors](https://onekey.dev/docs/monitors.md): the signature and delivery rules.
- [Billing webhooks](https://onekey.dev/docs/billing-webhooks.md): tell your own systems when credits run low.
