> ## Documentation Index
> Fetch the complete documentation index at: https://unif.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Jobs and exports

> Bulk work, handed off and collected later.

A job runs a search, enrichment or export asynchronously. Reach for one when the work exceeds what
a synchronous call will do: more than 100 enrichment inputs, more than 1,000 search results, or
any export to a file.

## Creating a job

```bash theme={null}
curl -X POST https://api.unif.dev/v1/jobs \
  -H "Authorization: Bearer $UNIF_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nightly-enrich-2026-09-15" \
  -d '{
    "type": "enrich",
    "input": {
      "currency": "USD",
      "period": { "preset": "last_30d" },
      "fields": ["name", "metrics.revenue", "metrics.revenue_growth_rate"],
      "inputs_url": "https://files.example.com/shops.jsonl"
    },
    "webhook_id": "whk_01k3m9x7v2q8r4t6y0b1n5d7fa"
  }'
```

The response returns immediately:

```json theme={null}
{
  "id": "job_01k3m9x7v2q8r4t6y0b1n5d7fa",
  "object": "job",
  "type": "enrich",
  "status": "queued",
  "progress": { "total": 20000, "completed": 0, "failed": 0 },
  "created_at": "2026-09-15T02:00:00Z"
}
```

<Note>
  Always send an `Idempotency-Key` when creating a job. A job is the one place where a retried
  request costs real money twice — see [Idempotency](/docs/platform/idempotency).
</Note>

## The three job types

<AccordionGroup>
  <Accordion title="enrich" icon="wand-magic-sparkles">
    Takes the same body as [`POST /enrich`](/docs/workflows/enrich), with either an `inputs` array
    or an `inputs_url` pointing at a JSONL file of inputs — one object per line, same shape as a
    synchronous input.

    ```json theme={null}
    { "type": "shop", "url": "https://www.tiktok.com/shop/glowlab", "ref": "acct-4471" }
    { "type": "shop", "url": "https://www.tiktok.com/shop/northpeak", "ref": "acct-4472" }
    ```
  </Accordion>

  <Accordion title="search" icon="magnifying-glass">
    Takes a [search body](/docs/workflows/search) with no `limit` or `cursor`, and returns the full
    result set past the 1,000-row synchronous depth by sharding the query for you.
  </Accordion>

  <Accordion title="export" icon="file-arrow-down">
    Takes `entity`, `format` (`csv` or `jsonl`) and a `search` body. Produces a file rather than
    paginated JSON.

    ```json theme={null}
    {
      "type": "export",
      "input": {
        "entity": "product",
        "format": "csv",
        "search": {
          "market": "US",
          "period": { "preset": "last_30d" },
          "filters": { "revenue": { "gte": 50000 } }
        }
      }
    }
    ```
  </Accordion>
</AccordionGroup>

## Collecting the result

Subscribe to `job.completed` and let the webhook tell you. Polling is the fallback, not the plan.

<CodeGroup>
  ```python Webhook (preferred) theme={null}
  @app.post("/webhooks/unif")
  def handle(event: dict):
      if event["type"] == "job.completed":
          job_id = event["data"]["job_id"]
          ingest.delay(job_id)          # hand off, return 200 immediately
      return "", 200
  ```

  ```python Polling (fallback) theme={null}
  import time

  def wait_for(job_id, timeout=3600):
      delay, waited = 5, 0
      while waited < timeout:
          job = get_job(job_id)
          if job["status"] in ("completed", "failed", "cancelled", "partially_completed"):
              return job
          time.sleep(delay)
          waited += delay
          delay = min(delay * 2, 60)     # back off; do not hammer
      raise TimeoutError(job_id)
  ```
</CodeGroup>

For `enrich` and `search` jobs, read the output from `GET /jobs/{id}/results` — cursor-paginated
like any other list. For `export` jobs the job itself carries a signed `download_url`, valid for
24 hours; `results` returns `409` for them.

```python theme={null}
job = get_job(job_id)

if job["type"] == "export":
    urllib.request.urlretrieve(job["download_url"], "products.csv")
else:
    for row in iter_results(job_id):
        ingest(row)
```

## Statuses

| Status                | Meaning                                                                                        |
| --------------------- | ---------------------------------------------------------------------------------------------- |
| `queued`              | Accepted, not started                                                                          |
| `running`             | In progress; `progress` updates as it goes                                                     |
| `completed`           | Every input processed                                                                          |
| `partially_completed` | Finished with some failures — `progress.failed` is non-zero, and results contain the successes |
| `failed`              | Could not run. `error` explains why; nothing was charged                                       |
| `cancelled`           | Stopped via `POST /jobs/{id}/cancel`                                                           |

<Warning>
  `partially_completed` is a success with holes, and it is the status most often mishandled. Read
  `progress.failed`, pull the results, and reconcile against your inputs by `ref` — do not assume
  a completed job returned a row for everything you sent.
</Warning>

## Cancelling

```bash theme={null}
curl -X POST https://api.unif.dev/v1/jobs/job_01k3m9x7v2q8r4t6y0b1n5d7fa/cancel \
  -H "Authorization: Bearer $UNIF_API_KEY"
```

Cancelling stops a `queued` or `running` job. Work already completed is still charged, and results
produced so far remain readable. A job that has already finished returns `409`.

## Practical limits

|                           | Limit           |
| ------------------------- | --------------- |
| Enrichment inputs per job | 1,000,000       |
| `inputs_url` file size    | 512 MB          |
| Export rows               | 5,000,000       |
| Result retention          | 30 days         |
| `download_url` lifetime   | 24 hours        |
| Concurrent running jobs   | 5 per workspace |

Jobs past the concurrency limit stay `queued` rather than failing, so a nightly batch that
overruns will drain rather than drop.
