One POST in.
One entity ID out.
JSON over HTTPS, bearer auth, idempotency keys, cursor pagination, dated versions. There are fourteen endpoints and you will mostly use two of them.
Quickstart
Create a key, send a string, get an ID. The rest of this page documents the parameters around those three steps.
import Spotit from "@spotit/node"; const key = process.env.SPOTIT_KEY; const spotit = new Spotit({ apiKey: key }); const { match, entity } = await spotit.resolve({ q: "nordwind logistik gmbh hamburg", jurisdiction: ["DE"], }); if (match.decision === "auto_accept") { console.log(entity.id, match.confidence); }
- 1Create a key
No card. spk_test_ reads the full graph and is never billed; spk_live_ bills. Start with the resolve and read scopes.
Get an API key - 2Send a string
Any of the five clients, or plain curl. One field in, and the response already carries the entity ID you will key everything else on.
- 3Store the ID, not the name
Put entity_id on your record and join on it from then on. Names change; the ID does not.
Four things that hold everywhere
Auth, idempotency, pagination and versioning behave the same on every endpoint, so you only learn them once.
Keys
spk_live_ bills and writes; spk_test_ reads the full graph, caps at 500 calls a day and is never billed. Scopes are resolve, read, search, watch and admin; thresholds, region pins and jurisdiction limits attach to the key too.
Idempotency
Send Idempotency-Key on any POST. We store the response for 24 hours and replay it byte-for-byte; reusing a key with a different body is a 409.
Pagination
Cursor-based, never offset. A list response carries next_cursor until it does not; pass it back as cursor. Cursors are stable across inserts.
Versioning
Dated versions pinned per key. Override per request with Spotit-Version: 2026-06-01. Breaking changes ship under a new date and the previous one runs for twelve months.
{ "data": [ { "event_id": "evt_01JXR4T8M2C9KD", "type": "entity.officer_changed", "entity_id": "ent_01JR8K3F5T2QW9" } ], "has_more": true, "next_cursor": "ev_1788524412_8c31f0" }
Spotit-Version: 2026-06-01 X-Request-Id: req_8c31f0a92e X-RateLimit-Limit: 100 X-RateLimit-Remaining: 97 X-RateLimit-Reset: 1788524412 X-Credits-Charged: 1 X-Region: eu-central-1
The endpoint index
Credits are charged per unit of work. Lineage, history and the source catalogue are free on every plan.
| Endpoint | Description | Credits | p50 |
|---|---|---|---|
| POST/v1/resolve | Resolve one messy input to a canonical entity | 1 | 38 ms |
| POST/v1/resolve/batch | Resolve a job of up to 10,000 records (1,000 on Developer) | 1 / record | — |
| GET/v1/entities/{id} | Read the full record, optionally as_of a date | 0.2 | 21 ms |
| GET/v1/entities/{id}/lineage | Source document behind each field | 0 | 27 ms |
| GET/v1/entities/{id}/history | Register events for the entity, 2009 onward | 0 | 34 ms |
| GET/v1/entities/{id}/relations | Parents, subsidiaries, branches, brands | 0.2 | 29 ms |
| GET/v1/search | Structured discovery; 100 results per page | 1 / page | 86 ms |
| POST/v1/match | Link two record sets to each other | 1 / pair | 54 ms |
| GET/v1/geo/{id} | Communes, districts, NUTS/LAU rollups | 0.2 | 18 ms |
| POST/v1/subscriptions | Subscribe an entity list or saved query | 0 | 44 ms |
| GET/v1/events | Poll change events from a cursor | 0 | 31 ms |
| GET/v1/sources | Registry catalogue and ingest cadence | 0 | 12 ms |
| GET/v1/jobs/{id} | Poll a batch job and fetch its results file | 0 | 9 ms |
| POST/v1/subscriptions/{id}/replay | Re-deliver a window of events | 0 | 38 ms |
Resolve
Takes one messy input and returns one entity, a calibrated confidence and a decision. Add include=explain while you are tuning; drop it in production to save 4 ms of serialisation.
| Parameter | Type | Notes |
|---|---|---|
| qrequired | string | The messy input. Free text, domain, email domain, VAT, LEI, DUNS, register number, EUID, brand or former name. |
| type | string | One of legal_entity, branch, brand, place. Narrows the candidate set; omit to search all four. |
| jurisdiction | string[] | ISO 3166-1 alpha-2 codes. Cuts latency roughly in half when you know the country. |
| hints | object | domain, postal_code, city, vat, registration_number, incorporated_year. Treated as evidence; they raise a candidate's score but never exclude one. |
| threshold | number | Overrides the key's auto_accept threshold for this call. Default 0.95; the review floor sits 0.15 below it and anything under the floor is a no_match. |
| include | string[] | alternatives · explain · lineage · relations · financials. Each adds fields, not credits. |
| as_of | string | ISO 8601 date. Resolve against the register state that day; defaults to today. |
| strict | boolean | Return 422 instead of a 200 with decision: no_match. Default false. |
Host: api.spotit.ai Authorization: Bearer spk_live_8f2c... Content-Type: application/json Idempotency-Key: 5f3a90e2-2c41-4c0e-9a77-2d1bd4e6a3c9
{ "q": "nordw1nd logistik g.m.b.h., am sandtorkai", "type": "legal_entity", "jurisdiction": ["DE"], "hints": { "domain": "nordwind-logistik.de", "postal_code": "20457" }, "threshold": 0.95, "include": ["alternatives", "explain"], "as_of": "2026-09-05" }
{ "request_id": "req_8c31f0a92e", "latency_ms": 41, "match": { "entity_id": "ent_01JR8K3F5T2QW9", "confidence": 0.987, "decision": "auto_accept", "matched_on": ["name", "domain", "postal_code"] }, "entity": { "id": "ent_01JR8K3F5T2QW9", "name": "Nordwind Logistik GmbH", "status": "active", "jurisdiction": "DE", "registry": { "register": "HRB", "number": "148902" } }, "alternatives": [ { "entity_id": "ent_01JQ9V2H7YB4KC", "name": "Nordwind Holding AG", "confidence": 0.612 } ] }
| Field | Type | Notes |
|---|---|---|
| match.entity_id | string | Canonical ID. Stable across renames, mergers and re-registrations. |
| match.confidence | number | 0–1, calibrated per jurisdiction. A 0.91 is right about 91% of the time. |
| match.decision | enum | auto_accept · review · no_match. Derived from confidence against the key's threshold; a no_match still returns 200 unless you send strict=true. |
| match.matched_on | string[] | Which fields agreed. Useful for logging why a match was accepted. |
| explain.scorers | object | Per-scorer contribution. Only present with include=explain. |
| explain.conflicts | string[] | Fields that disagreed with the winning candidate. |
| entity | object | The record itself. Same shape as GET /v1/entities/{id}. |
| alternatives | array | Runners-up, max 10, sorted descending. Only with include=alternatives. |
| request_id | string | Quote this in support tickets; it is indexed in our logs for 30 days. |
"explain": { "candidates_blocked": 71, "candidates_scored": 12, "scorers": { "lexical": 0.91, "embedding": 0.96, "registry_key": null, "geo": 0.98 }, "conflicts": [] }
decision is a band on the confidence axis, set by the threshold on your key. Branch on the band rather than on a number you had to pick yourself. A no_match returns 200 by default and a 422 when you send strict=true; neither is billed.
The entity object
Everything else on this page either produces this object, points at a field inside it, or tells you when one of those fields changed.
| Group | Fields | Notes |
|---|---|---|
| id | string | Stable across renames, mergers and re-registrations. |
| type | enum | legal_entity · branch · brand · place. |
| name | string | Current registered name. former_names carries the rest, with dates. |
| status | enum | active · liquidation · dissolved · merged · struck_off. |
| registry | object | authority, register, number, incorporated_on. |
| identifiers | object | lei, vat, euid, duns, national ids. Nulls where none is issued. |
| addresses | array | Registered seat and any filed operating addresses, with LAU codes. |
| classification | object | NACE 2.1, SIC and the national code, each with its source. |
| financials | object | 38 fields from filed accounts. Present for 61% of entities. |
| relations | object | parent, subsidiaries, branches, brands, officers (counts, not names). |
| geo | object | commune, district, NUTS and LAU, as geo_ ids. |
| lineage | array | Field, source document, published_at, observed_at. With include=lineage. |
{ "id": "ent_01JR8K3F5T2QW9", "type": "legal_entity", "name": "Nordwind Logistik GmbH", "status": "active", "registry": { "authority": "Amtsgericht Hamburg", "register": "HRB", "number": "148902", "incorporated_on": "2011-03-14" }, "identifiers": { "lei": "529900NWLG7K2XQF4T81", "vat": "DE815402337", "euid": "DEK1101R_HRB148902", "duns": null }, "former_names": [ { "name": "Nordwind Spedition GmbH", "until": "2019-04-02" } ], "classification": { "nace": ["52.29", "49.41"] }, "financials": { "revenue_eur": 98400000, "headcount": 412, "fiscal_year": 2024 }, "relations": { "parent": "ent_01JQ9V2H7YB4KC", "branches": 4, "officers": 3 }, "geo": { "commune": "geo_01JN2R7X8VD4KQ" } }
?include= adds lineage, relations or financials to the same object; ?as_of= rewinds every field to a date; /history returns the events that moved them. Nothing changes shape.
Batch
Up to 10,000 records per request on Team, 50,000 on Business, 1,000 on Developer — processed at roughly 340 records per second. Poll the job or hand us a callback URL; results are a newline-delimited JSON file valid for 7 days.
POST /v1/resolve/batch { "records": [ { "id": "crm-8812", "q": "Nordwind Logistik Gmbh" }, { "id": "crm-8813", "q": "nordwind-logistik.de" }, { "id": "crm-8814", "q": "DE815402337" } ], "threshold": 0.90, "callback_url": "https://hooks.acme.example/jobs" } 202 Accepted { "job_id": "job_01JYD3K8P2M6TQ", "record_count": 3, "status": "queued", "poll": "/v1/jobs/job_01JYD3K8P2M6TQ" }
| queued | Accepted, waiting for a worker |
| running | Partial results readable from the cursor |
| complete | results_url is a signed NDJSON link, 7-day TTL |
| failed | Nothing billed; error carries the failing record ids |
| expired | Older than 7 days; re-submit to regenerate |
POST /v1/resolve/batch also accepts multipart/form-data. Send a CSV with a header row, name the input column, get back the same file with entity_id, confidence and decision appended.
Lineage and point-in-time reads
Two mechanics do most of the work in regulated and diligence workflows: ?include=lineage attaches the source document behind each field, and ?as_of= rewinds the whole record.
{ "field": "financials.revenue_eur", "value": 98400000, "source": { "authority": "Bundesanzeiger", "document_id": "src_01JV3D8K1P6NQZ", "document_type": "annual_accounts", "fiscal_year": 2024, "published_at": "2025-11-08", "url": "https://bundesanzeiger.de/..." }, "observed_at": "2025-11-08T04:12:09Z", "extraction": { "method": "structured_xbrl", "confidence": 1.0 }, "supersedes": "src_01JC2R6P8B3TMV" }
{ "id": "ent_01JR8K3F5T2QW9", "as_of": "2018-01-01", "name": "Nordwind Spedition GmbH", "status": "active", "financials": { "revenue_eur": 41200000, "headcount": 168, "fiscal_year": 2016 } }
Webhook delivery
Every delivery carries a Spotit-Signature header. Verify it, reject anything older than five minutes, and remember that two secrets are live during a rotation.
Spotit-Signature: t=1773197251,v1=8f3c2a...,v1=c07be1... Content-Type: application/json { "event_id": "evt_01JXR4T8M2C9KD", "type": "entity.officer_changed", "occurred_at": "2026-03-11T00:00:00Z", "observed_at": "2026-03-11T04:47:31Z", "entity_id": "ent_01JR8K3F5T2QW9", "subscription_id": "sub_01JH9F2Q7XB4NM", "change": { "field": "relations.officers", "before": { "count": 2 }, "after": { "count": 3 } }, "source": { "authority": "Amtsgericht Hamburg", "document": "src_01JXQ0W5R8T2LP" } }
| Guarantee | At-least-once — make your handler idempotent on event_id |
| Retries | 1s, 5s, 30s, 5m, 1h, 6h, then dead-letter |
| Timeout | 5 s to a 2xx, or it counts as a failure |
| Replay | POST /v1/subscriptions/{id}/replay?from=…&to=… |
| Ordering | Not guaranteed — compare occurred_at, not arrival |
| Batching | Up to 50 events per POST when a burst backs up |
| Dead letter | Drain it from the subscription’s dead-letter queue |
| Secrets | Two live during a rotation; retire the old one when ready |
import { createHmac, timingSafeEqual } from "crypto"; // secrets is an array: during a rotation, two are live. export function verify(body, header, secrets) { const parts = header.split(",").map(s => s.split("=")); const t = parts.find(([k]) => k === "t")?.[1]; const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v); if (!t || Math.abs(Date.now() / 1000 - +t) > 300) return false; return secrets.some(secret => { const want = Buffer.from( createHmac("sha256", secret).update(t + "." + body).digest("hex"), "hex", ); return sigs.some(sig => { const got = Buffer.from(sig, "hex"); return got.length === want.length && timingSafeEqual(got, want); }); }); }
{ "name": "DACH industrials — insolvency watch", "query": { "jurisdiction": ["DE", "AT"], "nace": ["28.4", "28.9"], "revenue_eur": { "gte": 10e6 } }, "events": [ "entity.insolvency_filed", "entity.status_changed", "query.entered" ], "destination": { "url": "https://hooks.acme.example/watch", "secret_id": "whsec_01JR2M8F4K9TXQ" } }
MCP and tool schemas
Seven tools over MCP, inheriting the scopes and thresholds of the key you connect with. The tool descriptions are written for a model to read, and every result carries the citation fields your evaluator needs.
# Claude Code, Cursor, Zed, any MCP client claude mcp add --transport http spotit \ https://mcp.spotit.ai/mcp \ --header "Authorization: Bearer $KEY" # or run it locally npx @spotit/mcp --key $SPOTIT_KEY
A read-only key exposes only the read tools. Rate limits, thresholds, region pinning and jurisdiction scopes all follow the key — an agent cannot talk its way past them.
| spotit_resolve_entity | Messy input → entity ID + confidence |
| spotit_get_entity | Full record, optionally as_of a date |
| spotit_search | Structured filters: sector, geo, size, form |
| spotit_events | Registry events for a set of entities |
| spotit_lineage | Source document behind any field |
| spotit_relations | Parents, subsidiaries, branches, brands |
| spotit_geo | Communes, districts, NUTS/LAU rollups |
| Every result | Carries entity_id, source and observed_at, so groundedness is gradeable without a second retrieval pass. |
| No match | Returns the reasons rather than a plausible guess. |
| Refusal | Below the floor it returns the reasons, not the nearest plausible company. |
Client libraries
Every client is generated from the OpenAPI document, so a field that exists in the API exists in your editor's autocomplete the day it ships.
Rate limits
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset are always present. A 429 also carries Retry-After in whole seconds.
| Plan | Sustained | Burst | Batch rows | Concurrent jobs | Webhook endpoints |
|---|---|---|---|---|---|
| Developer | 10 rps | 20 | 1,000 | 1 | 1 |
| Team | 100 rps | 300 | 10,000 | 5 | 10 |
| Business | 500 rps | 1,500 | 50,000 | 25 | 50 |
| Enterprise | Negotiated | — | Bulk files | Unlimited | Unlimited |
Errors
Every error carries a stable machine code, the offending parameter, a documentation URL and the request ID our support team will ask for.
| Status | Type | When |
|---|---|---|
| 400 | invalid_request_error | Malformed body, unknown field, or a jurisdiction outside the coverage set. |
| 401 | authentication_error | Missing, malformed or revoked key. Check the spk_live_ / spk_test_ prefix. |
| 402 | credit_error | Monthly credits spent and the key has a hard cap set. |
| 403 | permission_error | The key's scopes do not include this endpoint or this region. |
| 404 | not_found_error | Entity ID does not exist, or was merged — the response carries merged_into. |
| 409 | conflict_error | Idempotency-Key reused with a different body inside the 24 h window. |
| 422 | no_match_error | Only with strict=true. The default returns 200 and decision: no_match. |
| 429 | rate_limit_error | Over the plan's rps or burst. Retry-After is always set. |
| 500 | api_error | Our fault. Safe to retry; the request_id is already in our logs. |
| 503 | region_error | Pinned region is degraded. Retry without a pin to fail over. |
{ "error": { "type": "invalid_request_error", "code": "jurisdiction_unknown", "message": "Jurisdiction 'XX' is not covered.", "param": "jurisdiction", "doc_url": "spotit.ai/docs/errors#jurisdiction", "request_id": "req_2b9d41c7f0" } }
{ "error": { "type": "no_match_error", "code": "below_floor", "message": "Best candidate 0.61 < floor 0.80", "reasons": ["no_key", "geo_conflict"], "best": "ent_01JQ8B4X2VT7RC", "billed": false, "doc_url": "spotit.ai/docs/errors#below_floor", "request_id": "req_5a1e93b7c2" } }
HTTP/1.1 429 Too Many Requests Retry-After: 2 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1788524412 { "error": { "type": "rate_limit_error", "code": "rps_exceeded", "doc_url": "spotit.ai/docs/errors#rate_limit", "request_id": "req_c40b8d1f6e" } }
Regions and residency
Entity IDs are global. Where a request is processed and where its logs land is a per-key setting, and failover never crosses a residency boundary: EU keys fail over within the EU, US and APAC keys return 503 rather than leave their region.
| Region | Host | p50 resolve | Failover | Plans |
|---|---|---|---|---|
| eu-central-1 | Frankfurt | 38 ms | eu-west-1 | All |
| eu-west-1 | Dublin | 41 ms | eu-central-1 | Team+ |
| us-east-1 | N. Virginia | 44 ms | — | Team+ |
| ap-southeast-1 | Singapore | 58 ms | — | Business+ |
Connectors
The integrations we build, document and support ourselves. The remaining ninety-odd are community-maintained against the OpenAPI spec and listed in the catalogue.
MCP server
Hosted SSE and stdio, seven tools, scoped by key.
Function schemas
Strict-mode tool definitions for OpenAI and Anthropic, generated from the spec.
LangChain
First-party toolkit plus an ID-keyed retriever.
Snowflake
Secure share, hourly, with resolve() as a table function.
BigQuery
Analytics Hub listing, same schema as the Snowflake share.
dbt
spotit_core package: 11 macros and staging models.
Salesforce
Managed package, four-field writeback, 15-minute sync.
HubSpot
App and property map, same writeback contract.
n8n & Zapier
Native blocks for both, resolve and enrich in a flow.
The whole spec is public.
Read the OpenAPI document, generate a client, and call the sandbox before you talk to anyone here. Test keys resolve against the full graph with a 500-call daily cap.