# Batch processing

Resolve thousands of company names with the v3 async API. Submit with custom_id, collect by polling or by webhook, and keep every row accounted for. Complete scripts in JavaScript and Python.

Resolution is asynchronous: every submit returns a `run_id` immediately and the result lands one to five minutes later. A batch is therefore two loops that do not need to run at the same time: **submit** everything, then **collect** everything.

## Two ways to collect

| | Polling | Webhooks |
| --- | --- | --- |
| How | `GET /v3/runs/{run_id}` until `200` | Pass `webhook_url` on submit; the result is POSTed to you |
| Cost | Free, 100 polls a minute | Free, billed on delivery like any other call |
| Best for | Scripts, one-off lists, no public endpoint | Services, large lists, anything you would otherwise poll for hours |

Both use `custom_id`: your own key for the row (1 to 200 printable ASCII characters), returned with the result and on the webhook, so you never have to keep a `run_id` to row mapping yourself. The webhook contract (signing, retries, payload) is in [Webhooks](/docs/guides/webhooks).

## Input file

A CSV with a stable key per row:

```csv
custom_id,name,location,context
1001,Datadog,"New York, NY",
1002,Delta,"Atlanta, GA",airline
1003,Toyota Motor Europe NV/SA,Brussels,
```

`location` and `context` are optional. Include the country in `location` when you have it (`CA, US`, not `CA`). See [Handling messy data](/docs/guides/handling-messy-data).

## Quick start

Submit every row, write the `run_id` next to it, then collect until nothing is pending. Re-run the collect step as often as you like; it only asks about rows without a result.

**JavaScript**

```javascript
import fs from 'node:fs';
import { parse } from 'csv-parse/sync';
import { stringify } from 'csv-stringify/sync';

const API = 'https://api.savvyiq.ai';
const headers = { apikey: process.env.SAVVYIQ_API_KEY };

// Phase 1: submit
const rows = parse(fs.readFileSync('input.csv'), { columns: true });
const pending = [];
for (const row of rows) {
  const input = { name: row.name, custom_id: row.custom_id, include: 'basis' };
  if (row.location) input.location = row.location;
  if (row.context) input.context = row.context;
  const res = await fetch(`${API}/v3/entity-resolution/async`, {
    method: 'POST',
    headers: { ...headers, 'content-type': 'application/json' },
    body: JSON.stringify(input),
  });
  if (res.status === 429) { await new Promise((r) => setTimeout(r, 60_000)); rows.push(row); continue; }
  const body = await res.json();
  pending.push({ ...row, run_id: body.run_id, status: res.ok ? 'pending' : `error ${res.status}` });
}
fs.writeFileSync('pending.csv', stringify(pending, { header: true }));

// Phase 2: collect (run later, and again until nothing is pending)
const results = [];
for (const row of pending.filter((r) => r.status === 'pending')) {
  const res = await fetch(`${API}/v3/runs/${row.run_id}?include=basis`, { headers });
  if (res.status === 202) continue;
  const result = await res.json();
  results.push({
    custom_id: row.custom_id,
    name: row.name,
    status: result.status,
    match_confidence: result.match_confidence ?? '',
    entity_id: result.entity?.id ?? '',
    legal_name: result.entity?.legal_name ?? result.candidate?.legal_name ?? '',
    jurisdiction: result.entity?.primary_legal_entity?.jurisdiction ?? result.candidate?.jurisdiction ?? '',
  });
}
fs.writeFileSync('results.csv', stringify(results, { header: true }));
```

**Python**

```python
import csv, os, time, requests

API = 'https://api.savvyiq.ai'
HEADERS = {'apikey': os.environ['SAVVYIQ_API_KEY']}

# Phase 1: submit
with open('input.csv') as f:
    rows = list(csv.DictReader(f))
pending = []
for row in rows:
    params = {'name': row['name'], 'custom_id': row['custom_id'], 'include': 'basis'}
    if row.get('location'):
        params['location'] = row['location']
    if row.get('context'):
        params['context'] = row['context']
    res = requests.post(f'{API}/v3/entity-resolution/async', json=params, headers=HEADERS)
    if res.status_code == 429:
        time.sleep(60)
        rows.append(row)
        continue
    body = res.json()
    pending.append({**row, 'run_id': body.get('run_id', ''), 'status': 'pending' if res.ok else f'error {res.status_code}'})
with open('pending.csv', 'w', newline='') as f:
    w = csv.DictWriter(f, fieldnames=pending[0].keys()); w.writeheader(); w.writerows(pending)

# Phase 2: collect (run later, and again until nothing is pending)
results = []
for row in [r for r in pending if r['status'] == 'pending']:
    res = requests.get(f"{API}/v3/runs/{row['run_id']}", params={'include': 'basis'}, headers=HEADERS)
    if res.status_code == 202:
        continue
    result = res.json()
    entity = result.get('entity') or {}
    candidate = result.get('candidate') or {}
    results.append({
        'custom_id': row['custom_id'],
        'name': row['name'],
        'status': result['status'],
        'match_confidence': result.get('match_confidence', ''),
        'entity_id': entity.get('id', ''),
        'legal_name': entity.get('legal_name') or candidate.get('legal_name') or '',
        'jurisdiction': (entity.get('primary_legal_entity') or {}).get('jurisdiction') or candidate.get('jurisdiction') or '',
    })
with open('results.csv', 'w', newline='') as f:
    w = csv.DictWriter(f, fieldnames=results[0].keys()); w.writeheader(); w.writerows(results)
```

## Collecting by webhook instead

Add `webhook_url` to the submit and drop phase 2. Each delivery carries the `custom_id` you sent, so the receiver writes straight into your table:

```bash
curl -X POST 'https://api.savvyiq.ai/v3/entity-resolution/async' \
  -H 'apikey: YOUR_API_KEY' \
  -H 'content-type: application/json' \
  -d '{"name": "Datadog", "location": "New York, NY", "custom_id": "1001", "include": "basis",
       "webhook_url": "https://example.com/hooks/savvyiq"}'
```

Verify the signature and handle redelivery as described in [Webhooks](/docs/guides/webhooks). A row is done when a delivery for its `custom_id` has been stored; anything still missing after your deadline can be polled by `run_id` exactly as above.

## Rate limits

- Submits: 600 per minute per account. On `429`, wait for `Retry-After` and resubmit the row. The scripts above sleep 60 seconds and retry.
- Polls: 100 per minute, free. Poll a batch in a loop with a pause, not each row in a tight loop.
- Keys share the account allowance, so running several workers with different keys does not raise the ceiling. See [Rate limits](/docs/rate-limits).

## Reading a row

- `status` is `matched` or `partial_match` with a non-null `entity`: a verified record. Store `entity.id`.
- `inconclusive` with a `candidate`: our best hypothesis, no ID. Route to review or resubmit with `location` or `context`; `actions[]` says what to add.
- `no_match`: research finished and found nothing.
- `not_found`: research did not finish. Resubmit the row.

See [Understanding API responses](/docs/handling-responses).

## Test on a subset first

Run 20 to 50 rows before the full list. Check the `inconclusive` rows: most are fixed by adding the country to `location` or a few words of `context`. Then run the rest.

## Output

`results.csv` keeps your `custom_id` and name, then our verdict: `status`, `match_confidence`, `entity_id`, `legal_name`, `jurisdiction`. Read the full record for any `entity_id` from [`/v3/entities/{id}`](/docs/apis/business-intelligence) when you need firmographics and industry codes.

The v2 batch flow (`/v2/entity-resolution/async` plus `/status/{request_id}`, tracked by `request_id`) keeps working; its reference is the [2026-07-30 edition](/docs/reference/2026-07-30/). New integrations should use v3: `custom_id` and webhooks remove the tracking file entirely.
