SavvyIQ

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

PollingWebhooks
HowGET /v3/runs/{run_id} until 200Pass webhook_url on submit; the result is POSTed to you
CostFree, 100 polls a minuteFree, billed on delivery like any other call
Best forScripts, one-off lists, no public endpointServices, 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 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.

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.

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.