TOVOREST API v1

TOVO REST API

Enrich CRM records with FCA Register and Companies House data on UK regulated firms and the individuals.

Base URL https://api.tovodata.co.uk/v1 Version v1 Updated 15 September 2026
Substituted in the page only. Nothing is stored or sent anywhere.

The API at a glance

AreaWhat it doesCost
CompaniesFetch and match by domain / name / FRN / CH number.1 credit per new record
PeopleFetch by IRN, match by name + company.1 credit per new record
SearchFiltered lists of firms and people, people at a firm, disciplinary history. Regulatory data and contact availability, never contact values.Free
BulkSync batches of up to 100 records; async jobs of up to 10,000 with cost estimates before you commit.1 credit per new record
CreditsBalance, plan, and a per-record ledger you can reconcile against your CRM.Free
Freshnessupdated_since polling and 410 Gone tombstones for removed records.Free

Authentication#

Every request is authenticated with a bearer token in the Authorization header. Two kinds of token are accepted.

API keys

Keys are issued per organisation in the TOVO dashboard. The prefix tells you which environment a key belongs to.

PrefixEnvironmentBehaviour
tovo_live_ProductionReal data, real credits.
tovo_test_SandboxSame endpoints, deterministic fixture data, simulated credits. Never charged.
Header
Authorization: Bearer YOUR_API_KEY

OAuth 2.0 client credentials

Exchange a client ID and secret for a short-lived access token. Tokens last one hour; request a new one when expires_in runs out — there is no refresh token in this grant.

Request
curl -X POST https://auth.tovodata.co.uk/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d scope="read enrich jobs credits"
200 OK
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read enrich jobs credits"
}

Scopes

API keys carry a fixed set of scopes chosen when the key is created. OAuth clients request them at token time. A request outside its token's scopes fails with 403 insufficient_scope.

ScopeGrants
readSearch endpoints, people-at-a-firm, disciplinary history, and reads of records your organisation holds at their current version. Never spends credits.
enrichFetch and match — any call that can charge a credit. Implies read.
jobsCreate, poll, and cancel enrichment jobs.
creditsRead the credit balance and transaction ledger.

Key handling

  • Treat keys and client secrets as passwords. Never embed them in client-side code, mobile apps, or public repositories.
  • Send them only over HTTPS. Plain-HTTP requests are rejected, not redirected.
  • Rotate a key from the dashboard. The old key keeps working for 24 hours so you can roll deployments.
  • Give integrations that only read data a read-only key. A leaked read-only key cannot spend your credits.

Conventions#

TopicConvention
Base URLhttps://api.tovodata.co.uk/v1. The version is in the path. Breaking changes ship as a new version; /v1 stays stable.
FormatJSON in and out, UTF-8. Send Content-Type: application/json on requests with a body.
Namingsnake_case for every field, parameter, and enum value, in requests and responses alike.
Identifiersfrn, irn, and ch_number are always strings, even when they look numeric. FRNs and CH numbers keep their leading zeros.
Dates and timesISO 8601. Timestamps are UTC with a Z suffix (2026-09-15T09:12:03Z); dates without a time are YYYY-MM-DD.
MoneyIntegers in the currency's minor unit is not used; amounts are whole-currency numbers alongside an explicit currency field ("revenue": 14940000000, "currency": "GBP").
NullsA field TOVO knows about but has no value for is null. A field is never omitted from a response because it is empty — unless you asked for a subset with fields.
Sparse fields?fields=frn,name,contact on any fetch or search returns only those top-level fields plus identifiers. Use it to keep payloads under Salesforce's 6 MB callout limit.
CompressionSend Accept-Encoding: gzip. Responses over 1 KB are compressed.
Request IDsEvery response carries X-Request-Id. Quote it when contacting support; it is also inside every error body and every credit transaction.
IdempotencySend Idempotency-Key: <uuid> on any POST that can spend credits or create a job. Replaying the same key within 24 hours returns the original response and charges nothing more. A different body under a reused key fails with 409 idempotency_conflict.
DeprecationAnything scheduled for removal answers with Deprecation: true and a Sunset date header, and is listed in the changelog at least 12 months ahead.

Errors#

Errors are returned with a non-2xx status and a body in the RFC 9457 Problem Details format, with Content-Type: application/problem+json. Branch on code, not on detail — the human-readable text may change, the code will not.

FieldTypeDescription
typestringURI identifying the error class. Stable; safe to link to.
titlestringShort human-readable summary.
statusintegerHTTP status, repeated in the body.
detailstringWhat went wrong in this specific case.
codestringMachine-readable code from the error catalogue.
request_idstringMatches the X-Request-Id header.
errorsobject[]On 422 only: one entry per invalid field, with field and message.
creditsobjectOn 402 only: required and balance.
Insufficient credits
{
  "type": "https://api.tovodata.co.uk/errors/insufficient_credits",
  "title": "Insufficient credits",
  "status": 402,
  "detail": "This request needs 1 credit; your balance is 0.",
  "code": "insufficient_credits",
  "request_id": "req_01J8ZK3M9QW2X7",
  "credits": { "required": 1, "balance": 0 }
}

A 402 never charges and never returns contact values. The matched record still comes back with its regulatory data and contact_available, so you can show exactly what a top-up would buy. The full list of codes is in the error catalogue.

Rate limits#

Limits apply per organisation, across all keys, in a rolling one-minute window. Every response reports where you stand.

HeaderMeaning
RateLimit-LimitRequests allowed in the current window.
RateLimit-RemainingRequests left in the current window.
RateLimit-ResetSeconds until the window resets.
Retry-AfterOn 429 only: seconds to wait before retrying.
Endpoint groupStandardEnterprise
Fetch, match, search600 / minNegotiated
Sync batch60 / minNegotiated
Enrichment jobs10 concurrentNegotiated

On 429 rate_limited, wait for Retry-After and retry with exponential backoff. Bulk work belongs in enrichment jobs, which are not subject to the per-minute limit — the queue paces them.

Pagination#

Every list endpoint is cursor-paginated. Cursors are opaque and stable across concurrent inserts, so a long sync never skips or duplicates a record.

ParameterTypeDefaultDescription
limitinteger25Records per page, 1–100.
cursorstringThe next_cursor from the previous page. Omit for the first page.
List envelope
{
  "data": [ { "...": "one record per element" } ],
  "next_cursor": "eyJvZmZzZXQiOjI1LCJ0IjoiMjAyNi0wOS0xNSJ9",
  "has_more": true
}

Stop when has_more is false; next_cursor is null on the final page. Cursors expire after 24 hours. Search endpoints do not return a total count — it is expensive to compute and rarely needed for enrichment.

Get a company#

GET/v1/companies/{frn}

Fetches a single firm by its FCA Firm Reference Number and returns the complete record, contact details included. Charges a credit unless your organisation already holds the firm's current version — in which case it is free, and returns exactly the same payload. Once TOVO changes the record, the copy you hold is superseded and the next fetch charges again: see Keeping data fresh. To read a named firm's regulatory data, use search with filters.frn.

ParameterInTypeDefaultDescription
frnpathstringFirm Reference Number, e.g. 122702.
requirequerystringComma-separated contact fields the firm must have before a credit is spent: phone, linkedin, any_contact.
fieldsquerystringallComma-separated top-level fields to return.
Request
curl "https://api.tovodata.co.uk/v1/companies/122702?require=any_contact" \
  -H "Authorization: Bearer YOUR_API_KEY"
200 OK
{
  "frn": "122702",
  "ch_number": "01026167",
  "name": "Barclays Bank PLC",
  "trading_names": ["Barclays", "Barclays Business"],
  "website": "https://www.barclays.com",
  "domain": "barclays.com",
  "fca_status": "authorised",
  "fca_status_effective_date": "2001-12-01",
  "permissions": ["accepting_deposits", "dealing_in_investments_as_principal", "arranging_deals_in_investments"],
  "client_money_permission": "hold_and_control",
  "principal_firm": null,
  "appointed_representatives_count": 2,
  "disciplinary_history": { "count": 3, "latest_at": "2024-11-19" },
  "ch_status": "active",
  "incorporated_at": "1925-08-04",
  "sic_codes": [{ "code": "64191", "description": "Banks" }],
  "registered_address": { "line_1": "1 Churchill Place", "city": "London", "postcode": "E14 5HP", "country": "GB" },
  "officers_count": 120,
  "employee_count": 24000,
  "employee_band": "10001+",
  "counts": { "cf_individuals": 3279, "smf_individuals": 31, "directors": 109 },
  "sector_activities": [{ "path": ["Lending & Risk", "Bank (Retail)"] }],
  "summary": "Barclays Bank PLC is a UK clearing bank authorised...",
  "financials": {
    "period_end": "2025-03-31",
    "currency": "GBP",
    "revenue": 14940000000,
    "ebitda": -1994000000,
    "net_assets": 61496999936
  },
  "aum": { "value": 382400000000, "currency": "GBP", "as_of": "2025-03-31" },
  "financial_health": { "score": "strong", "based_on_filing_date": "2025-03-31" },
  "location": { "city_region": "City of London, London", "postcode": "E14 5HP" },
  "contact": {
    "phone":    [{ "value": "+442071161000", "type": "switchboard" }],
    "linkedin": [{ "value": "https://www.linkedin.com/company/barclays-bank", "type": "company" }]
  },
  "purchased_at": "2026-09-15T09:31:44Z",
  "last_updated_at": "2026-09-10T12:04:17Z",
  "sources": ["fca_register", "companies_house", "tovo"]
}
StatusWhen
200Found. Complete record, contact included — charged unless you already owned it, or require was not satisfied.
402The record needs a credit and the balance is zero. The body carries the firm's regulatory data and contact_available, but no contact values.
404No firm with this FRN.
410The firm existed and has been removed from TOVO. See Keeping data fresh.

Field definitions are in the Company object.

Match a company#

POST/v1/companies/match

Finds the firm that best matches what you know about it. Send any combination of identifiers; the more you send, the higher the confidence. This is the Account-enrichment call.

FieldTypeRequiredDescription
domainstringAt least oneWebsite domain, e.g. barclays.com. Scheme, www., and paths are stripped. The strongest single key.
namestringCompany or trading name. Fuzzy-matched; punctuation and suffixes (Ltd, PLC, LLP) are normalised.
frnstringExact FRN. Overrides fuzzy matching when present.
ch_numberstringExact Companies House number.
min_confidencenumberNoDefault 0.85. Range 0–1.
requirestring[]NoContact fields the firm must have before a credit is spent: phone, linkedin, any_contact. All listed fields must be present. Unset, every match charges.
fieldsstring[]NoTop-level company fields to return.
Request
curl -X POST https://api.tovodata.co.uk/v1/companies/match \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c9a1e-3b7d-4a5e-9c2f-8d4e1b7a2c3d" \
  -d '{
    "domain": "barclays.com",
    "name": "Barclays"
  }'
200 OK — 1 credit charged
{
  "match": {
    "status": "matched",
    "confidence": 0.99,
    "matched_on": ["domain", "name"]
  },
  "company": {
    "frn": "122702",
    "ch_number": "01026167",
    "name": "Barclays Bank PLC",
    "domain": "barclays.com",
    "fca_status": "authorised",
    "contact": {
      "phone":    [{ "value": "+442071161000", "type": "switchboard" }],
      "linkedin": [{ "value": "https://www.linkedin.com/company/barclays-bank", "type": "company" }]
    },
    "purchased_at": "2026-09-15T09:14:51Z",
    "...": "remaining Company object fields"
  },
  "candidates": [],
  "credits": { "charged": 1, "balance": 4998 }
}

An ambiguous result is a decision for a human or for stricter input, not an error. Pick a candidate and call GET /v1/companies/{frn} to buy it, or retry the match with the candidate's frn. A name-only match of a group with many regulated entities — banks, insurers — is the common cause; adding the domain almost always resolves it.

People at a company#

GET/v1/companies/{frn}/peopleFree

Lists the individuals holding FCA functions at a firm. The "find contacts at this Account" call. Free, and like search it returns availability rather than contact values — buy the individuals you want with GET /v1/people/{irn}.

ParameterInTypeDefaultDescription
frnpathstringFirm Reference Number.
currentquerybooleantrueOnly people with a current role at the firm. false includes past roles.
smf_functionsquerystringComma-separated SMF codes, e.g. SMF1,SMF3,SMF16.
has_contactquerybooleantrue returns only people with at least one contact field. Listing is free either way; this narrows the list before you start spending on it.
fields, limit, cursorquerySee Pagination.
Request
curl "https://api.tovodata.co.uk/v1/companies/433927/people?smf_functions=SMF1,SMF3&has_contact=true" \
  -H "Authorization: Bearer YOUR_API_KEY"
200 OK
{
  "data": [
    {
      "irn": "PCW00020",
      "name": "Paul Cheriton Wreford",
      "current_roles": [{ "role": "Executive Director", "smf_code": "SMF3", "frn": "433927", "firm_name": "Hoyl Independent Advisers Ltd", "effective_date": "2024-09-13" }],
      "contact_available": { "email": true, "phone": false, "linkedin": true },
      "purchased_at": null,
      "credits_to_fetch": 1
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Get a person#

GET/v1/people/{irn}

Fetches a single individual by their FCA Individual Reference Number and returns the complete record, contact details included. Charges a credit unless your organisation already holds the person's current version — in which case it is free, and returns exactly the same payload. Once TOVO changes the record, the copy you hold is superseded and the next fetch charges again: see Keeping data fresh. To read a named individual's regulatory data without buying it, use search with filters.irn.

ParameterInTypeDefaultDescription
irnpathstringIndividual Reference Number, e.g. PCW00020.
requirequerystringComma-separated contact fields the person must have before a credit is spent: email, phone, linkedin, any_contact. ?require=email means “free unless there is an email”.
fieldsquerystringallComma-separated top-level fields to return.
Request
curl https://api.tovodata.co.uk/v1/people/PCW00020 \
  -H "Authorization: Bearer YOUR_API_KEY"
200 OK — 1 credit charged
{
  "irn": "PCW00020",
  "name": "Paul Cheriton Wreford",
  "first_name": "Paul",
  "last_name": "Wreford",
  "avatar_url": null,
  "fca_status": "approved",
  "smf_functions": [{ "code": "SMF3", "name": "Executive Director" }],
  "certification_functions": ["client_dealing"],
  "controlled_functions": [],
  "disciplinary_history": { "count": 0, "latest_at": null },
  "current_roles": [
    { "role": "Executive Director", "smf_code": "SMF3", "frn": "433927", "firm_name": "Hoyl Independent Advisers Ltd", "effective_date": "2024-09-13" }
  ],
  "employment_history": [
    { "frn": "433927", "firm_name": "Hoyl Independent Advisers Ltd", "role": "Executive Director", "from": "2024-09-13", "to": null, "is_current": true },
    { "frn": "186310", "firm_name": "Smith & Williamson Financial Services", "role": "Adviser", "from": "2016-02-01", "to": "2024-08-31", "is_current": false }
  ],
  "location": { "city_region": "Norwich, Midlands" },
  "contact": {
    "email":    [{ "value": "p.wreford@hoyl-advisers.com", "type": "work", "confidence": "high", "verified_at": "2026-08-30" }],
    "phone":    [],
    "linkedin": [{ "value": "https://www.linkedin.com/in/paul-wreford", "type": "profile" }]
  },
  "purchased_at": "2026-09-15T09:12:03Z",
  "last_updated_at": "2026-09-10T15:49:24Z",
  "sources": ["fca_register", "tovo"]
}
StatusWhen
200Found. Complete record, contact included — charged unless you already owned it, or require was not satisfied.
402The record needs a credit and the balance is zero. The body carries the person's regulatory data and contact_available, but no contact values.
404No individual with this IRN.
410The individual existed and has been removed from TOVO. Purge your copy — see Keeping data fresh.

Field definitions are in the Person object.

Match a person#

POST/v1/people/match

Finds the individual that best matches a name and the firm they work at. This is the Contact- and Lead-enrichment call. Email and LinkedIn are outputs of this endpoint, not inputs — TOVO identifies people by who they are and where they are approved, not by their inbox.

FieldTypeRequiredDescription
namestringOne ofFull name as written. Middle names and initials help; honorifics are ignored.
first_name, last_namestringSplit name, if that is what your CRM holds.
companyobjectYes, unless irnThe firm. Any of frn, domain, name. FRN is exact; domain and name resolve through company matching first.
irnstringNoIf you already have it, matching is exact and company is optional. Prefer GET /v1/people/{irn} in that case.
role_hintstringNoJob title from your CRM. Used as a tie-breaker between people with the same name at the same firm.
min_confidencenumberNoDefault 0.85.
requirestring[]NoContact fields the person must have before a credit is spent: email, phone, linkedin, any_contact. All listed fields must be present. Unset, every match charges.
fieldsstring[]NoTop-level person fields to return.
Request
curl -X POST https://api.tovodata.co.uk/v1/people/match \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2b8e4d10-7f3a-4c96-b1e5-0a9d3c6f8e21" \
  -d '{
    "first_name": "Paul",
    "last_name": "Wreford",
    "company": { "domain": "hoyl-advisers.com" },
    "role_hint": "Director",
    "min_confidence": 0.9
  }'
200 OK — 1 credit charged
{
  "match": {
    "status": "matched",
    "confidence": 0.94,
    "matched_on": ["first_name", "last_name", "company.domain", "role_hint"]
  },
  "person": {
    "irn": "PCW00020",
    "name": "Paul Cheriton Wreford",
    "current_roles": [{ "role": "Executive Director", "smf_code": "SMF3", "frn": "433927", "firm_name": "Hoyl Independent Advisers Ltd", "effective_date": "2024-09-13" }],
    "contact": {
      "email":    [{ "value": "p.wreford@hoyl-advisers.com", "type": "work", "confidence": "high", "verified_at": "2026-08-30" }],
      "phone":    [],
      "linkedin": [{ "value": "https://www.linkedin.com/in/paul-wreford", "type": "profile" }]
    },
    "purchased_at": "2026-09-15T09:12:03Z",
    "...": "remaining Person object fields"
  },
  "candidates": [],
  "credits": { "charged": 1, "balance": 4997 }
}

Common names at large firms produce ambiguous results. role_hint and a split first_name/last_name both raise confidence; so does an FRN instead of a company name. The 402 body is unusual in carrying the matched record itself — regulatory data and contact_available, but no contact values — so a top-up flow can show the user exactly what the credit would buy.

Sync batch match#

POST/v1/people/match/batch
POST/v1/companies/match/batch

Matches up to 100 records in one request and returns every result inline. Built for real-time use from Salesforce — a batch of 100 completes well inside the 120-second Apex callout limit. For more than 100 records, or when you don't need the answer immediately, use enrichment jobs.

FieldTypeRequiredDescription
recordsobject[]Yes1–100 entries. Each is a person match or company match body plus a ref. Over 100 fails with 413 batch_too_large.
records[].refstringYesYour identifier for the record — a Salesforce Id, a row number. Echoed unchanged in the matching result and in the credit ledger. Must be unique within the batch.
min_confidencenumberNoDefault 0.85. Applies to every record.
requirestring[]NoApplies to every record in the batch. Records that fail it come back matched but without a contact block, carrying "charged": 0, "reason": "requirements_not_met".
fieldsstring[]NoTop-level fields to return per record. Strongly recommended for batches — 100 full Person objects is a large response.
Request
curl -X POST https://api.tovodata.co.uk/v1/people/match/batch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9c4f2a7e-1d3b-4e8a-a6c5-7b2d9f0e3a18" \
  -d '{
    "min_confidence": 0.9,
    "require": ["email"],
    "fields": ["irn", "name", "current_roles", "contact"],
    "records": [
      { "ref": "003Ab00000Xk1QzIAB", "name": "Paul Cheriton Wreford", "company": { "domain": "hoyl-advisers.com" } },
      { "ref": "003Ab00000Xk1R0IAB", "name": "James Smith",           "company": { "name": "Barclays" } },
      { "ref": "003Ab00000Xk1R1IAB", "name": "Nobody Realname",        "company": { "frn": "122702" } }
    ]
  }'
200 OK
{
  "results": [
    {
      "ref": "003Ab00000Xk1QzIAB",
      "match": { "status": "matched", "confidence": 0.94, "matched_on": ["name", "company.domain"] },
      "person": { "irn": "PCW00020", "name": "Paul Cheriton Wreford", "current_roles": [ "..." ], "contact": { "email": [ "..." ], "phone": [], "linkedin": [ "..." ] } },
      "credits": { "charged": 1 }
    },
    {
      "ref": "003Ab00000Xk1R0IAB",
      "match": { "status": "ambiguous", "confidence": 0.62, "matched_on": ["name", "company.name"] },
      "person": null,
      "candidates": [ { "irn": "JSM01822", "name": "James Smith", "current_role": "Compliance Oversight (SMF16)", "frn": "122702", "confidence": 0.62 }, "..." ],
      "credits": { "charged": 0 }
    },
    {
      "ref": "003Ab00000Xk1R1IAB",
      "match": { "status": "not_found", "confidence": 0, "matched_on": [] },
      "person": null,
      "credits": { "charged": 0 }
    }
  ],
  "summary": { "total": 3, "matched": 1, "ambiguous": 1, "not_found": 1, "already_current": 0, "new": 1, "updated": 0, "requirements_not_met": 0, "purchased": 1, "with_email": 1, "with_phone": 0, "with_linkedin": 1 },
  "credits": { "charged": 1, "balance": 4996 }
}

summary carries the same breakdown a job reports, so a small selection gets its download summary without the round-trip of a dry run.

A batch is all-or-nothing only for validation: a malformed record fails the whole request with 422 before anything is charged. Once accepted, each record is processed independently, and the response is 200 even if every record is not_found. If your balance runs out mid-batch, the remaining matched records come back without their contact blocks and with "credits": { "charged": 0, "reason": "insufficient_credits" } — the batch still succeeds. Records skipped by require carry "reason": "requirements_not_met" in the same place.

Enrichment jobs#

Jobs process up to 10,000 records asynchronously — or fewer, if your plan's limits.max_records_per_job is lower. You submit, poll for completion, then download results. They are the right tool for backfilling a CRM, and the only tool that tells you what a run will cost before it runs.

Create a job

POST/v1/enrichment_jobs
FieldTypeRequiredDescription
resourcestringYespeople or companies. A job handles one resource type.
recordsobject[]Yes1–10,000 match bodies, each with a unique ref. Same shape as a sync batch.
min_confidencenumberNoDefault 0.85.
requirestring[]NoApplies to every record. Set it before a large backfill: it is the difference between paying for 3,104 matches and paying for the 1,402 that carry an email.
dry_runbooleanNoDefault false. When true, the job matches every record but buys nothing and charges nothing; it exists to produce credits.estimated_max and the counts breakdown.
fieldsstring[]NoTop-level fields in each result.
Request — estimate first
curl -X POST https://api.tovodata.co.uk/v1/enrichment_jobs \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resource": "people",
    "dry_run": true,
    "min_confidence": 0.9,
    "require": ["email"],
    "records": [
      { "ref": "003Ab00000Xk1QzIAB", "name": "Paul Cheriton Wreford", "company": { "domain": "hoyl-advisers.com" } },
      { "ref": "003Ab00000Xk1R0IAB", "name": "James Smith", "company": { "frn": "122702" }, "role_hint": "Compliance" }
    ]
  }'
202 Accepted
{
  "id": "job_01J8ZN2C6WQ4HT",
  "resource": "people",
  "status": "queued",
  "dry_run": true,
  "min_confidence": 0.9,
  "require": ["email"],
  "counts": { "total": 4200, "processed": 0, "matched": 0, "ambiguous": 0, "not_found": 0, "failed": 0, "already_current": 0, "new": 0, "updated": 0, "requirements_not_met": 0, "purchased": 0, "with_email": 0, "with_phone": 0, "with_linkedin": 0 },
  "credits": { "estimated_max": null, "charged": 0 },
  "created_at": "2026-09-15T10:02:11Z",
  "started_at": null,
  "completed_at": null,
  "results_url": null
}

The response headers include Location: /v1/enrichment_jobs/job_01J8ZN2C6WQ4HT and Retry-After: 15. When a dry run completes, credits.estimated_max is the number of records that matched at or above min_confidence, satisfied require, and are not already owned — the most a real run of the same records can cost. Submit the real job with the same records, the same require, and dry_run omitted.

Poll a job

GET/v1/enrichment_jobs/{id}Free

Returns the job object. Poll no faster than the Retry-After header suggests — it shrinks as the job nears completion. Jobs progress through queuedrunningcompleted, or end in failed or cancelled.

200 OK — completed
{
  "id": "job_01J8ZN2C6WQ4HT",
  "resource": "people",
  "status": "completed",
  "dry_run": true,
  "min_confidence": 0.9,
  "require": ["email"],
  "counts": { "total": 4200, "processed": 4200, "matched": 3104, "ambiguous": 611, "not_found": 485, "failed": 0, "already_current": 786, "new": 430, "updated": 186, "requirements_not_met": 1702, "purchased": 0, "with_email": 1402, "with_phone": 2190, "with_linkedin": 1876 },
  "credits": { "estimated_max": 616, "charged": 0 },
  "created_at": "2026-09-15T10:02:11Z",
  "started_at": "2026-09-15T10:02:14Z",
  "completed_at": "2026-09-15T10:09:47Z",
  "results_url": "https://api.tovodata.co.uk/v1/enrichment_jobs/job_01J8ZN2C6WQ4HT/results"
}

Read this as a download summary: 3,104 of 4,200 records matched; 786 of those you already hold at their current version, and require: ["email"] rules out a further 1,702 that carry no email. That leaves 616 creditsnew 430 records you have never bought, plus updated 186 you own but TOVO has changed since. Those two counts are the web app's “including N new/updated records” line, and their sum is always what gets charged. Drop require and the same run costs 2,318, because every match charges.

Fetch results

GET/v1/enrichment_jobs/{id}/resultsFree

Available once status is completed (or cancelled, for the records processed before cancellation). Results are in submission order and each carries its ref. Two formats:

AcceptReturnsUse when
application/json (default)Cursor-paginated { data, next_cursor, has_more }, up to 100 results per page.Salesforce and other callers with response-size limits.
application/x-ndjsonOne result per line, the whole job in a single streamed response.Scripts and data pipelines.
200 OK — one result
{
  "ref": "003Ab00000Xk1QzIAB",
  "match": { "status": "matched", "confidence": 0.94, "matched_on": ["name", "company.domain"] },
  "person": { "irn": "PCW00020", "name": "Paul Cheriton Wreford", "contact_available": { "email": true, "phone": false, "linkedin": true } },
  "credits": { "charged": 0, "would_charge": 1 }
}

Dry-run results carry contact_available and credits.would_charge, never contact values — so you can see per record both what a real run would spend and what it would return. Results are retained for 30 days.

List and cancel

GET/v1/enrichment_jobsFree
DELETE/v1/enrichment_jobs/{id}Free

The list is cursor-paginated, newest first, filterable by status. Cancelling a queued or running job stops it at the next record; nothing is charged for records not yet processed, and the records already bought stay bought and readable for free. Cancelling a finished job returns 409.

Credit balance#

GET/v1/creditsFree

Your organisation's current balance, plan, and per-request record caps. Balances are shared across every key and OAuth client in the organisation.

200 OK
{
  "balance": 4996,
  "plan": { "name": "Growth", "credits_per_period": 5000, "period": "monthly" },
  "period_started_at": "2026-09-01T00:00:00Z",
  "renews_at": "2026-10-01T00:00:00Z",
  "used_this_period": 4,
  "rollover": false,
  "limits": {
    "max_records_per_batch": 100,
    "max_records_per_job": 10000
  }
}

Credits are consumed in the order they were granted; with rollover: false, unused credits expire at renews_at. Every enrichment response also carries credits.balance, so most integrations only call this endpoint to render a dashboard.

plan.nameStarting creditsNotes
Trial100Granted on account creation, not renewed. Live keys, real data.
GrowthPer credits_per_periodRenews on renews_at.
EnterprisePer contractBounded by the credit balance rather than a download ceiling; limits still applies per request.

Read limits rather than hard-coding a cap: it is plan-dependent and can change without a version bump. Exceeding either is 413 batch_too_large, with the applicable cap in the problem body — split the work into more requests, or more jobs.

Credit transactions#

GET/v1/credits/transactionsFree

The ledger. One entry per credit spent, with enough context to reconcile it to the CRM record that caused it. Cursor-paginated, newest first.

ParameterTypeDescription
from, tostringISO 8601 bounds on occurred_at.
resourcestringperson or company.
resource_idstringAn IRN or FRN — every charge ever made against one record.
job_idstringCharges from one enrichment job.
refstringCharges tagged with one of your own identifiers.
limit, cursorSee Pagination.
200 OK
{
  "data": [
    {
      "id": "txn_01J8ZM1B8XR5TE",
      "occurred_at": "2026-09-15T09:12:03Z",
      "credits": 1,
      "resource": "person",
      "resource_id": "PCW00020",
      "resource_name": "Paul Cheriton Wreford",
      "source": "people.match",
      "request_id": "req_01J8ZM0V3KQ9RD",
      "job_id": null,
      "ref": "003Ab00000Xk1QzIAB",
      "key_id": "key_live_4f2a"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

source names the endpoint that spent the credit (people.get, people.match, people.match.batch, enrichment_jobs, and the company equivalents). key_id identifies which key or OAuth client made the call. The ledger is retained indefinitely.

Keeping data fresh#

TOVO does not push changes. Two pull mechanisms keep a CRM in step; a nightly scheduled job using both is the recommended pattern.

A changed record costs a credit to retrieve. Your credit buys the version of the record you were given. When TOVO changes that record — a new email, a new role, a status change — the version you hold is superseded, and retrieving the new one is new information at the usual price. Re-reading a record that has not changed stays free, however often you do it.

Removed records: 410 Gone

When TOVO removes a record — an individual exercising their data-protection rights, a firm merged away, a duplicate collapsed — its fetch endpoint returns 410 permanently, with a tombstone body. This is how a customer holding a copy finds out they must purge it.

410 Gone
{
  "type": "https://api.tovodata.co.uk/errors/gone",
  "title": "Gone",
  "status": 410,
  "detail": "This record was removed on 2026-09-01 and will not return.",
  "code": "gone",
  "request_id": "req_01J8ZP4W7NM2KA",
  "removed_at": "2026-09-01T00:00:00Z",
  "reason": "data_subject_request",
  "superseded_by": null
}
reasonMeaningAction
data_subject_requestThe individual asked for their data to be removed.Delete your copy. Retaining it may breach UK GDPR.
mergedDuplicate collapsed into another record.Re-point to superseded_by. What you paid for carries over: the surviving record is charged only if it has changed since you bought the record it absorbed.
deregisteredFirm dissolved or struck off with no successor.Mark inactive.

Tombstones never appear in search results, so a refresh alone will not find them. Once a week, search for your stored identifiers in batches of 1,000 (filters.irn / filters.frn) and treat any identifier missing from the results as removed — then confirm with a fetch, which returns 410. A tombstone never charges, whatever the record's state. The Salesforce package does this on a schedule; a hand-rolled integration should too.

Your account#

GET/v1/meFree

Describes the credential making the call. The first request every integration should make: it confirms the key works, which environment it points at, and what it is allowed to do.

200 OK
{
  "organisation": { "id": "org_01H9QK2V8M", "name": "Acme Wealth Ltd" },
  "credential": {
    "type": "api_key",
    "id": "key_live_4f2a",
    "environment": "live",
    "scopes": ["read", "enrich", "jobs", "credits"],
    "created_at": "2026-06-02T14:20:00Z",
    "expires_at": null
  },
  "rate_limit": { "limit": 600, "window_seconds": 60 },
  "credits": { "balance": 4996 }
}

credential.type is api_key or oauth_client; for a rotated key, expires_at shows when the old value stops working.

Company object#

Returned by every company endpoint. Fields are grouped by source; all are free except the contact block, which is present only on records your organisation owns. Every other field is present in every response unless trimmed with fields; a value TOVO does not hold is null.

Identity

FieldTypeDescription
frnstringFCA Firm Reference Number. Primary identifier.
ch_numberstringCompanies House number, with leading zeros.
namestringRegistered name, in its registered casing.
trading_namesstring[]Trading names on the FCA Register.
websitestringFull URL with scheme.
domainstringNormalised domain, the matching key.

FCA Register

FieldTypeDescription
fca_statusenumauthorised, registered, eea_authorised, appointed_representative, no_longer_authorised, cancelled.
fca_status_effective_datedateWhen the current status began.
permissionsstring[]Regulated activities, as snake_case codes. Full list in Search filters.
client_money_permissionenumhold_and_control, control_only, none.
principal_firmobjectFor appointed representatives: { frn, name } of the principal. Otherwise null.
appointed_representatives_countintegerCurrent ARs of this firm.
disciplinary_historyobject{ count, latest_at }.

Companies House

FieldTypeDescription
ch_statusenumactive, dissolved, liquidation, administration, dormant.
incorporated_atdateIncorporation date.
sic_codesobject[]{ code, description }. Code is a string.
registered_addressobject{ line_1, line_2, city, postcode, country }. Country is ISO 3166-1 alpha-2.
officers_countintegerCurrent CH officers.

Firmographics and financials

FieldTypeDescription
employee_countintegerBest estimate of headcount.
employee_bandenum1-10, 11-50, 51-200, 201-1000, 1001-10000, 10001+.
countsobject{ cf_individuals, smf_individuals, directors } — people holding controlled functions, senior management functions, and directorships.
sector_activitiesobject[]Analyst-verified activity taxonomy. Each { path: ["Sector", "Activity"] }, most specific last.
summarystringOne-paragraph description.
financialsobject{ period_end, currency, revenue, ebitda, net_assets, profit_before_tax } from the latest filed accounts.
aumobject{ value, currency, as_of }. Assets under management where disclosed.
financial_healthobject{ score, based_on_filing_date }. score: strong, good, fair, weak, distressed, or null when data is insufficient.
locationobject{ city_region, postcode } of the principal place of business.

Contact and meta

FieldTypeDescription
contactobjectPaid block with phone[] and linkedin[]. Present only on firms you own; contact_available stands in its place elsewhere. See Contact & match blocks.
purchased_attimestampWhen your organisation last bought this firm, or null. Later than last_updated_at means the copy you hold is current and free to re-read.
last_updated_attimestampLast change to any field. Drives updated_since.
sourcesstring[]Which of fca_register, companies_house, tovo contributed.

Person object#

Returned by every people endpoint and inside People at a company.

Identity and FCA Register

FieldTypeDescription
irnstringFCA Individual Reference Number. Primary identifier.
namestringFull name as registered.
first_name, last_namestringParsed from name.
avatar_urlstringProfile image where available.
fca_statusenumapproved, certified, no_longer_approved, prohibited.
smf_functionsobject[]{ code, name } — e.g. { "code": "SMF16", "name": "Compliance Oversight" }. Codes are filterable; names are for display.
certification_functionsstring[]snake_case codes, e.g. client_dealing, material_risk_taker.
controlled_functionsstring[]Legacy CF codes for individuals approved before SM&CR.
disciplinary_historyobject{ count, latest_at }. Detail via GET /v1/people/{irn}/disciplinary.

Employment

FieldTypeDescription
current_rolesobject[]{ role, smf_code, frn, firm_name, effective_date }. One entry per current approval; most senior first.
employment_historyobject[]{ frn, firm_name, role, from, to, is_current }. Newest first. to is null while current.
locationobject{ city_region }.

Contact and meta

FieldTypeDescription
contactobjectPaid block with email[], phone[], linkedin[]. Present only on people you own; contact_available stands in its place elsewhere.
purchased_attimestampWhen your organisation last bought this individual, or null. Later than last_updated_at means the copy you hold is current and free to re-read.
last_updated_attimestampLast change to any field.
sourcesstring[]fca_register, tovo.

Contact & match blocks#

Contact block

Identical shape on Company (phone, linkedin) and Person (email, phone, linkedin). One shape only — there is no locked variant.

FieldTypePresent whenDescription
contactobjectYour organisation owns the recordAbsent otherwise: on ambiguous and not_found, on 402, on a match that failed require, and on every search result.
<field>object[]Always, inside contactEvery value held, best first. Empty array when TOVO holds nothing — never null, never absent.

Availability and ownership, outside the block

Two fields say what a record holds and whether you own it, without exposing values. Both appear wherever contact is withheld, and purchased_at also appears on records you own.

FieldTypeWhereDescription
contact_availableobjectSearch results, 402 bodies, requirements_not_met responses, dry-run job results{ email, phone, linkedin } booleans — whether TOVO holds at least one value for each. Companies omit email.
purchased_attimestampEverywhereWhen your organisation last bought this record; null if never. Compare with last_updated_at: if TOVO has changed the record since, the copy you hold is superseded.
credits_to_fetchintegerSearch results0 or 1 — what retrieving this record right now would cost you. 1 when purchased_at is null (never bought) or older than last_updated_at (bought, then changed). Saves the client comparing timestamps.

Value objects

FieldApplies toTypeDescription
valueallstringThe email address, E.164 phone number, or full LinkedIn URL.
typeallenumEmail: work, personal. Phone: direct, mobile, switchboard. LinkedIn: profile, company.
confidenceemailenumhigh (verified deliverable), medium (pattern-derived, domain verified), low (pattern-derived only).
verified_atemail, phonedateLast successful verification, or null.

Match block

Returned by both match endpoints, by batch results, and inside job results.

FieldTypeDescription
statusenummatched, ambiguous, not_found.
confidencenumber0–1. The best candidate's score, whatever the status.
matched_onstring[]Input fields that contributed, in order of weight. Company inputs are prefixed company..
Candidate fieldTypeDescription
irn / frnstringIdentifier — enough to fetch the record directly.
namestringRegistered name.
current_rolestringPeople only. "Role (SMFn)" for display.
domainstringCompanies only.
confidencenumberThis candidate's score.

Up to 5 candidates, descending by confidence. Candidates carry neither a contact block nor contact_available — pick one and fetch it.

Job & transaction objects#

Enrichment job

FieldTypeDescription
idstringjob_-prefixed identifier.
resourceenumpeople, companies.
statusenumqueued, running, completed, failed, cancelled.
dry_run, min_confidence, requireAs submitted.
countsobject{ total, processed, matched, ambiguous, not_found, failed, already_current, new, updated, requirements_not_met, purchased, with_email, with_phone, with_linkedin }. failed counts records that errored individually; the job still completes. new (never bought) and updated (bought, then changed by TOVO) are the two chargeable groups; already_current is free. The with_* counts cover the matched set and let you render a download summary from one dry run.
creditsobject{ estimated_max, charged }. estimated_max is null until the job completes; on a dry run it equals new + updated.
created_at, started_at, completed_attimestampLifecycle. Later ones null until reached.
results_urlstringSet on completion.
errorobjectOn failed: a problem object explaining why.

Credit transaction

FieldTypeDescription
idstringtxn_-prefixed.
occurred_attimestampWhen the credit was spent.
creditsintegerAlways 1 in v1. Reserved for future pricing.
resource, resource_id, resource_namestringWhat was bought. One entry per record, never per field — so the ledger line count is the credit count.
sourcestringEndpoint that spent it, dotted: people.match, companies.get, enrichment_jobs, …
request_idstringThe X-Request-Id of the spending call.
job_idstringSet when spent by a job.
refstringYour identifier, when the call supplied one.
key_idstringWhich credential made the call.

Search filters#

Filters go in the filters object of a search request. Array filters match any listed value. Filters that accept include/exclude take either a bare array (shorthand for include) or { "include": [...], "exclude": [...] }; a value in both is excluded. Range filters take { "min", "max" }, either optional.

Companies

FilterTypeMatches
frnstring[]Exact FRNs, up to 1,000. For refresh syncs.
fca_statusenum[]Values as on the Company object.
permissionsinclude/excludeRegulated activity codes: accepting_deposits, advising_on_investments, arranging_deals_in_investments, dealing_in_investments_as_agent, dealing_in_investments_as_principal, managing_investments, insurance_distribution, consumer_credit, mortgage_advice, payment_services, e_money, crypto_asset_registration.
client_money_permissionenum[]hold_and_control, control_only, none.
locationstring[]City/region names, e.g. London, Edinburgh, Midlands.
employee_bandenum[]Bands as on the Company object.
sic_codesstring[]Exact 5-digit codes.
sector_activitiesinclude/excludeTaxonomy paths joined with > , e.g. Lending & Risk > Bank (Retail). A sector alone matches every activity under it.
financial_healthenum[]Scores as on the Company object.
revenue, ebitda, net_assets, aumrangeGBP.
smf_individualsrangeCount of people holding SMFs.
authorised_betweenrange (dates)On fca_status_effective_date.
has_disciplinarybooleanAny disciplinary history.
has_contactbooleanAt least one contact field available. Search is free; use this to build a list you are willing to pay for, and require to enforce it when you buy.
fetch_stateenum[]new (never bought), updated (bought, then changed by TOVO), current (bought and unchanged — free to fetch). ["new","updated"] scopes a list to exactly what a run would charge for.
is_appointed_representativebooleanFirm is an AR of a principal.

People

FilterTypeMatches
irnstring[]Exact IRNs, up to 1,000.
fca_statusenum[]Values as on the Person object.
smf_functionsinclude/excludeSMF codes: SMF1 (Chief Executive) … SMF27 (Partner). Codes, not names.
certification_functionsstring[]Codes as on the Person object.
locationstring[]City/region names.
firm_frnstring[]Currently approved at any of these firms.
firm_fca_status, firm_employee_band, firm_permissions, firm_sector_activities, firm_net_assets, firm_financial_healthas companyFilter people by attributes of their current firm. Same semantics as the company filter without the prefix.
role_started_betweenrange (dates)On the current role's effective_date. Finds recent movers.
has_disciplinarybooleanAny disciplinary history.
has_contactbooleanAt least one contact field available.
has_emailbooleanAn email is available. The list-building half of require: ["email"].
fetch_stateenum[]new, updated, current — as for companies. Pair with updated_since to find people whose details have moved since you bought them.

Error codes#

Every code the API can return, with its status. type is always https://api.tovodata.co.uk/errors/<code>.

StatuscodeMeaningRetry?
400invalid_requestMalformed JSON, unknown parameter, or wrong content type.No — fix the request.
401unauthorizedMissing, malformed, expired, or revoked credential.No — check the key or refresh the token.
402insufficient_creditsThe call would charge and the balance is zero. Body includes the matched record without its contact values.After topping up.
403insufficient_scopeCredential lacks the scope for this endpoint.No.
403environment_mismatchA test key was used against a live-only feature, or vice versa.No.
404not_foundNo record, job, or transaction with that identifier.No.
409idempotency_conflictAn Idempotency-Key was reused with a different body.No — use a new key.
409job_not_cancellableJob has already finished.No.
410goneRecord permanently removed. Body includes reason.No — purge or re-point.
413batch_too_largeMore records than your plan's limits allow — by default 100 in a batch, 10,000 in a job. Body carries limit.Split it.
422validation_failedBody parsed but a field is invalid. errors[] lists each one.No — fix the fields.
429rate_limitedOver the per-minute limit.After Retry-After.
500internal_errorTOVO fault. Quote request_id to support.Once, with backoff.
503unavailablePlanned maintenance or overload.After Retry-After.