Guides
Batch processing
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.
Input file
A CSV with a stable key per row:
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.
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.
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 })); 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:
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. 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 forRetry-Afterand 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.
Reading a row
statusismatchedorpartial_matchwith a non-nullentity: a verified record. Storeentity.id.inconclusivewith acandidate: our best hypothesis, no ID. Route to review or resubmit withlocationorcontext;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.
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} 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. New integrations should use v3: custom_id and webhooks remove the tracking file entirely.