Contact Enrichment API
Contact Enrichment API — Append Phones, Emails & Related Persons to CRM Records
Send a name and address. Get back phones, emails, and — on Deep Search — the full related-persons graph. Integrate with one bearer token. Pay per match.
What is Contact Enrichment?
Contact enrichment is the process of appending missing or outdated contact information — phone numbers, email addresses, address history, related persons — to existing records in a CRM, database, or lead list. Instead of manually searching for contact details or buying expensive bulk data subscriptions, you call an enrichment API with the information you already have and receive the missing fields back in the response.
One scoping note before you read further, because it decides whether this is the contact enrichment API you want: it does not return job titles, employers, firmographics, or technographics. What it appends is residential contact data — phones, emails, address history, and related people such as spouses, relatives, and known associates — against a person record. If you are shopping for company-side enrichment, this is the wrong API, and you will save time knowing it now.
Ava Data's contact enrichment API specializes in person-level data: given a full name and a known address (or city and state), it returns the phone numbers and email addresses on file for that person (Standard Search lists mobile numbers first), and optionally the related-persons graph. This is PI-grade skip tracing delivered as a JSON API, on the same $9/month plan as the dashboard and billed per match — no separate API contract, no usage minimum.
Internal links: Skip Tracing API · Phone Number Lookup API · Bulk Skip Tracing · CRM Enrichment glossary · People Search API
What Fields Does the Contact Enrichment API Return?
Two endpoints, two response shapes. Every response is wrapped as { "success": true, "data": { ... } }, and every field below is defined in the OpenAPI spec the API reference is generated from.
Standard Search — POST /api/v1/standard-search
Field on data | Type | Notes |
|---|---|---|
| matchFound | boolean | Check this before reading anything else. |
| creditsCharged | integer | One credit per requested data type when a match is found, even if a type comes back empty: ["phone"] = 1, ["email"] = 1, ["phone","email"] = 2. List each type once — a repeated type is charged again. 0 on a miss. |
| phones[] | array | Objects of { number, lineType }, mobile numbers first (up to two), then a landline, then the rest; lineType is missing when unknown. Present only when "phone" was requested and a match was found. |
| emails[] | array of strings | Plain email strings, such as "john.smith@example.com". Present only when "email" was requested and a match was found. |
| name | object | { first, last }: the matched person as Ava Data has them on file. Present on a match. |
| addresses[] | array | Objects of { address, city, state, zip }, plus zip4 when known. Present on a match. |
| matchType | string | Individual or Household: whether the match is to the person or to their household. Present on a match. |
phones[].lineType is a plain string describing the line, such as Mobile or Landline, and it can be missing. It is not a closed enum, so parse it defensively rather than switching on a fixed set of values.
Deep Search — POST /api/v1/deep-search
Field on data | Type | Notes |
|---|---|---|
| matchFound | boolean | Same contract as Standard Search. |
| creditsCharged | integer | 10 when the result includes a phone, an email or a related person; 0 otherwise, including a match with no contact data. |
| subject | object | firstName, lastName, aliases[], age, dob, deceased, phones[] ({ number, lineType, disconnected }), emails[] (plain strings), addresses[] ({ address, city, state, zip }), bankruptcy. |
| relatedPeople[] | array | Each entry carries firstName, lastName, age, deceased, relationship, phones[], emails[], addresses[], bankruptcy. phones[] holds the related person's own numbers; emails[] and addresses[] come back empty. |
relationship is the label describing the tie back to the subject — the spec's example is Spouse. It is a free-text string with no fixed list of values, so display it, do not switch on it. Note the asymmetry too: aliases[] and dob exist on subject only, not on entries in relatedPeople[]. Deep Search takes no dataTypes parameter — it always returns the full shape.
One field the response does not carry: a persistent person identifier. No schema in the spec returns an ID you can store and re-query later, so when you use this as an identity resolution API the resolution key stays on your side — match the returned name and addresses back to your own record, and re-resolve on the next call rather than expecting a stable ID to join on.
What a no-match looks like
A no-match is not an error. The API returns HTTP 200 with matchFound: false and creditsCharged: 0, and nothing is billed for a miss. The spec defines phones and emails as present only when that data type was requested and a match was found, so on a miss they are not there to read at all. Branch on matchFound first and treat every array as optional — code that reaches straight for data.phones.length breaks on the first miss.
deceased and bankruptcy come back as public-record flags. Ava Data is not a consumer reporting agency and this data is not a consumer report: it may be used to locate and contact a person, never to decide eligibility for credit, insurance, employment, or housing.
Identity Resolution API vs. Ava Data's Identity Graph
Developers evaluating enrichment vendors often arrive looking for an identity resolution API, so it is worth being exact about the line, because the two categories solve different problems.
Identity resolution, as the term is used in martech and CDP work, means collapsing many records scattered across many systems into one canonical entity — deterministic and probabilistic matching that produces a persistent identifier which survives across calls, so you can maintain a golden record over time and recognize the same human on the next visit.
The Ava Data API does not do that, and does not return a persistent person ID. There is no entity key on subject or on relatedPeople[], there is no GET endpoint that retrieves a person by identifier, and nothing in a response lets you join two separate calls together as provably the same individual. The only identifiers in the spec belong to jobs, lists, properties and log entries — jobId, lookupId, listId, the List Builder propertyId (a property, not a person), and the activity id on GET /api/v1/usage. Every search is a stateless, point-in-time answer keyed by the identity you sent in. If you need a durable ID to deduplicate a warehouse or stitch profiles across systems, that is a customer data platform's job, not this API's.
What Ava Data does have is an identity graph, and it solves the adjacent problem: turning a partial record into a reachable person. The graph stores edges between people — shared addresses over time, shared surnames at the same property, and other public-record connections — and a Deep Search walks those edges outward from the subject. The input is a person, not an anonymous fragment: lastName is required on both /api/v1/standard-search and /api/v1/deep-search, Standard Search also needs a name with a street and ZIP or a city and state before it can run, and firstName, address, city, state, and zip narrow it to the right individual. What comes back is contact channels plus, on Deep Search, a labeled set of adjacent people. It expands outward from one person rather than collapsing many records into one.
Two response fields do real work if you are thinking in resolution terms, and neither appears on both endpoints. Standard Search returns matchType on data — Individual or Household — labeling the match made on that call. Deep Search returns subject.aliases[], a list of name variants you can use to reconcile spellings inside your own database. Both are per-call signals, not durable keys.
The practical pattern: keep your own record ID as the identity spine, call the API with what that record already holds, and write the result back onto it. Your CRM stays the system of record — for Follow Up Boss, our one live CRM integration does exactly this, writing enrichment results back to the lead as an append-only note.
Enriching at Scale — Synchronous and Batch
Both modes are documented endpoint by endpoint in the API reference.
Synchronous enrichment is the right pattern when a record has to be enriched the moment it enters your system — a new lead form submission, a CRM record created by a rep, an inbound inquiry that needs a callback number before the first follow-up. You POST to /api/v1/standard-search or /api/v1/deep-search and write the JSON straight onto the record. Note the required inputs: lastName is mandatory on both endpoints, and dataTypes is additionally mandatory on Standard Search. Standard Search also needs a name plus street and ZIP or city and state to run at all, and the address components are what sharpen the match, so send everything the record has.
Batch enrichment handles backfill — an entire CRM export, a stale prospect database, a list that needs phones before a campaign. It is a three-call async flow, and it runs Standard Search only; Deep Search is a single-search endpoint with no bulk equivalent.
curl -X POST https://app.avadata.ai/api/v1/bulk/upload \ -H "Authorization: Bearer sk_live_your_api_key_here" \ -F "file=@leads.csv" \ -F 'dataTypes=["phone","email"]'
{
"success": true,
"data": {
"jobId": "abc123-def456",
"lookupId": "xyz789",
"recordCount": 500,
"dataTypes": ["phone", "email"],
"creditsPerMatch": 2,
"status": "processing",
"message": "Processing 500 records. Use GET /api/v1/bulk/abc123-def456/status to check progress."
}
}
Three details catch people out. The upload is multipart/form-data, not JSON, with the file in a field named file (100 MB and 250,000 rows at most). dataTypes is sent as a JSON array string in the form field, not as a repeated field. And every CSV row needs first and last name plus ZIP, first and last name plus city and state, or street address plus city, state and ZIP, under header names the API recognizes — first name, last name, address, city, state, zip and their listed variants, in any case, because the API has no column mapping. Rows that meet none of the three are skipped, never charged, and counted in skippedCount on the upload response.
Then poll GET /api/v1/bulk/{jobId}/status. status moves through uploading → queued → processing → completed, or lands on failed with an errorMessage. The response also carries progress (0–100) and a nullable estimatedSecondsRemaining you can use to set your next poll interval instead of hammering a fixed timer — keys are rate limited to 60 requests per minute, and that ceiling covers your polling as well as your lookups. There are no webhooks — polling is the only completion signal, so build for it rather than waiting on a callback that will not arrive.
When the job completes, call GET /api/v1/bulk/{jobId}/download?layout=standard. It returns numRecords, numMatches, creditsCharged, a columns list, and a results array of row objects keyed by column name: your uploaded columns first, in their original order, then Match Found, Phone 1–6 with Line Type 1–6, Mobile 1–3, Landline 1–3, and Email 1 onward. Put an ID column of your own in the upload and it comes back on every row, which is how you join results to your database; rows skipped at upload are not in the results. Without layout=standard, a bulk upload's results is usually the whole results file as one CSV string, so always send it. Add &format=csv if you would rather have the file back than the JSON. Credits are reserved upfront at the quoted creditsPerMatch and then cut to the credits actually used, 1 for each requested data type returned per row, once processing finishes, so the reservation is a ceiling, not a bill.
The two modes share the same data and the same per-match billing, so mix them freely — synchronous lookups for new records, batch jobs for periodic refresh — with no separate contract and no separate pricing tier. See Bulk Skip Tracing for the batch workflow end to end.
Pricing
| Lookup type | Credits | Price per match | Returns |
|---|---|---|---|
| Standard — Phone Only | 1 | $0.02 | The phone numbers on file, mobile first, each with a line type when known |
| Standard — Phone + Email | 2 | $0.04 | The same phones plus the email addresses on file (2 credits on any match) |
| Deep Search | 10 | $0.20 | Phones, emails, related persons (spouses, siblings, associates) |
Credits are only charged when a match is returned. No match = no charge. See full plan details →
Authenticating and Operating the Contact Enrichment API
Keys are generated from Settings → API Access and carry an sk_live_ prefix, sent as Authorization: Bearer sk_live_.... API access is on for every account, so a 401 on a key that looks well formed usually means the key was revoked or copied with a missing character; generate a new one in Settings → API Access.
The rate limit is 60 requests per minute, per key. That ceiling is what decides which of the two enrichment modes a job belongs in: synchronous calls are for records arriving one at a time, and a 10,000-record backfill run synchronously would take just under three hours pinned at the limit. Send that job to /bulk/upload instead — a multipart upload of the CSV plus a dataTypes value, and one request against the limit rather than ten thousand.
Every failure shares one envelope — success: false and an error message, with a code, a field or extra details on some errors — so a contact enrichment API client can parse errors with one code path that checks the status first and code when it is present:
{
"success": false,
"error": "Insufficient credits"
}
| Status | What it means | What to do |
|---|---|---|
| 400 | Invalid or missing request parameters, or a search that can't run as sent. | Usually a missing or empty lastName, a missing dataTypes on Standard Search, or code INSUFFICIENT_INPUT: Standard Search needs a name plus street and ZIP or city and state, and a Deep Search that matches too many people needs a first name, ZIP, or city and state. Not retryable — fix the payload. |
| 401 | Missing, invalid, or revoked API key, API access turned off, or account suspended. | Check that the header is exactly Authorization: Bearer sk_live_…, then generate a new key in Settings → API Access. If it still fails, email support. |
| 402 | Insufficient credits. | Top up. Pre-flight long runs against GET /credits and read availableCredits — bulk jobs reserve credits upfront and settle to the credits actually used afterward. |
| 403 | No active plan, a paused plan, a feature not switched on for the account, or a job that belongs to another account. | Code SUBSCRIPTION_REQUIRED: start the $9/month plan. SUBSCRIPTION_PAUSED: resume it (resumeAt is the automatic resume time). "Access denied": the job is not yours. |
| 404 | The requested resource does not exist. | In practice a bad jobId on a bulk or audience endpoint. An enrichment search that finds nobody is a 200 with matchFound: false, never a 404. |
| 413 | Request too large. | A JSON body over 1 MB (BODY_TOO_LARGE) or a CSV over 100 MB (FILE_TOO_LARGE). Split the file. |
| 429 | Over 60 requests per minute on this key. | Wait the number of seconds in the Retry-After header, then retry. The body carries code RATE_LIMITED and the same number as retryAfter. |
| 500 | Internal server error. | Retryable after a short wait. Any credits charged are refunded on the search endpoints, so a retry does not leave you double-billed for the failure. |
On retries and idempotency: the search and CSV upload endpoints accept no idempotency key; only the List Builder calls POST /lists and POST /lists/{listId}/skip-trace take an Idempotency-Key header. A 429 did no work and a 500 refunds on the search endpoints, so both are safe to retry: a 429 after the seconds in its Retry-After header, a 500 after a short wait. The case to handle yourself is a client-side timeout on a request the server actually completed — retrying that one can bill a second match. Key your own outbound calls by your CRM record ID, and reconcile with GET /usage, whose recentActivity rows carry the endpoint, status code, credits used, and timestamp for each request.
Each endpoint's own status codes are listed in the full API reference, generated from the OpenAPI spec.
Frequently Asked Questions
Does the contact enrichment API return a persistent person ID?
No. Ava Data performs identity resolution at query time rather than exposing a persisted entity you can address later. Neither subject nor relatedPeople[] carries an entity identifier, and no endpoint retrieves a person by ID — the identifiers the API hands back (jobId and lookupId on a bulk upload, listId and propertyId from List Builder) address a job, a list or a property rather than a person. Each search is a stateless, point-in-time answer keyed by the identity you send, so two calls about the same person cannot be joined by anything Ava Data returns. If you need identity resolution that persists across calls, keep your own record ID as the spine and write results back onto it.
Can I enrich a record from an address alone?
Not through the synchronous endpoints. lastName is required on both /standard-search and /deep-search. Address, city, state and zip are what let Standard Search run and sharpen the match, but a name is the key the search is built on. If you are starting from a list of property addresses, a bulk file accepts rows with street, city, state and ZIP and no name; for a single address, get the owner name first — public assessor and deed records carry it — then enrich.
What happens when there is no match?
You get HTTP 200 with data.matchFound: false and data.creditsCharged: 0. Credits are charged only when a match is returned, so misses cost nothing. Branch on data.matchFound before reading anything else: on a Standard Search miss the phones and emails arrays are absent from data rather than returned empty, so indexing into them without checking is what breaks first.
Does the rate limit apply to bulk polling too?
Yes. The 60 requests per minute is per API key across every endpoint — searches, uploads, status polls and downloads all draw on the same budget and all return 429 when it is exceeded. That is the argument for moving large backfills onto /bulk/upload: one request covers the whole file instead of one request per record, leaving the budget free for the status polls that follow. A 429 here carries a Retry-After header with the seconds until the key's window resets, so wait that long before retrying.
Are there webhooks or an idempotency key?
The API emits no outbound callbacks. Only the two List Builder calls, POST /lists and POST /lists/{listId}/skip-trace, accept an Idempotency-Key header; the search and upload endpoints don't. Bulk job completion is discovered by polling GET /bulk/{jobId}/status, using the returned progress and estimatedSecondsRemaining to pace your interval rather than polling on a fixed tight loop. Retrying is safe on both documented failure classes — a 429 means nothing was processed, and on a 500 any credits charged by a search endpoint are refunded — but wait between attempts (on a 429, the seconds its Retry-After header gives), and because searches and uploads have no idempotency key, deduplicate retried timeouts on your side and check GET /usage before retrying one. If you want event-driven enrichment without writing a poller, the Follow Up Boss integration triggers a trace from a CRM webhook instead.
How to Integrate
Authentication is a bearer token in the Authorization header. No OAuth flow, no SDK, no required library — anything that can send an HTTP POST works. Keys are generated in Settings → API Access (API access is on for every account), and each key is rate limited to 60 requests per minute; exceed it and the endpoint returns 429 with a Retry-After header saying how many seconds to wait. Here is a complete, copy-pasteable synchronous contact enrichment call:
curl -X POST https://app.avadata.ai/api/v1/standard-search \
-H "Authorization: Bearer sk_live_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Robert",
"lastName": "Chen",
"address": "1400 Harbor Blvd",
"city": "Fullerton",
"state": "CA",
"zip": "92832",
"dataTypes": ["phone", "email"]
}'
{
"success": true,
"data": {
"matchFound": true,
"phones": [
{ "number": "7145550198", "lineType": "Mobile" }
],
"emails": ["rchen@example.com"],
"name": { "first": "Robert", "last": "Chen" },
"addresses": [
{ "address": "1400 Harbor Blvd", "city": "Fullerton", "state": "CA", "zip": "92832" }
],
"matchType": "Individual",
"creditsCharged": 2
}
}
lastName and dataTypes are the only fields the schema requires, but the search also needs a name plus street and ZIP or city and state to run; the address components narrow the match. Swap the path for /api/v1/deep-search and drop dataTypes to get the subject object plus the relatedPeople array back instead. For backfills, the same data is available through the asynchronous bulk endpoints — upload, poll status, download — covered on the Bulk Skip Tracing page. Two account-level GETs handle metering: GET /api/v1/credits returns availableCredits alongside the subscription, wallet and reserved balances behind it, and GET /api/v1/usage returns request and credit totals with a paginated recentActivity log.
The full endpoint reference — every parameter, response field and status code — is at the API reference, generated from the OpenAPI spec. API access is on for every account: generate a key in Settings → API Access.