Most data APIs answer a question and forget it. You search, you get rows back, you write them to a file, and the next time you need them you run the search again. The data has no home and no memory.
The ZoomInfo Audiences API is built on the opposite idea. An audience is a table that lives in ZoomInfo: rows of contacts or companies, columns of data you define, and a record of everything you have matched, enriched, and computed. You build it once, then operate on it: add a column, enrich a slice, filter the result, hand it to an agent. It behaves less like a query endpoint and more like a database you share with ZoomInfo's intelligence.
This guide covers both ways in. The first is conversational: three MCP tools let any connected agent create an audience, define its columns, and load rows, with enrichment running in ZoomInfo. The second is programmatic: the REST API covers the whole lifecycle from your own code, and a full worked example runs end to end with curl.
What an audience actually is
An audience has three structural pieces.
Rows
Individual records. Each row is one contact or one company, depending on the audience type. Rows hold cells, one per column.
Columns
The fields on every row. You define them. A column has a name, a data type, and a type that determines where its values come from.
Folders
Containers that group related audiences, by campaign, region, or team. Every audience lives in a folder; if you do not name one, the API creates a folder matching the audience name.
Two properties are fixed the moment you create an audience. The first is type: CONTACT for person-level records or COMPANY for account-level records. The second is origin, which records how the audience was first populated. When you build through this API the origin is CUSTOM, meaning the audience has no linked source dataset and you control its contents directly.
Everything else is mutable. You add and remove columns, upsert and delete rows, rename the audience, move it between folders, and run enrichment as many times as you need. The recordCount updates itself as rows come and go.
The mental model worth holding: a spreadsheet where any column can be grounded in ZoomInfo's data graph or generated by AI, and where every change is an API call.
The four column types
Columns are where an audience stops being a static list and becomes a programmable dataset. Four types are available through this API, and the difference between them is where each column's values come from.
CUSTOM
Static values you provide. The email you upload, the campaign name you tag, the note a rep left. ZoomInfo never touches these; they are your input and your source of truth.
ZOOMINFO_MATCH
The grounding column. It resolves each row against ZoomInfo's database and holds the matched identifier (ZI_CONTACT_ID or ZI_COMPANY_ID). This is the anchor that ties a row you uploaded to a real entity ZoomInfo knows about.
FORMULA
Computed values. You write a prompt that becomes a JavaScript expression evaluated per row, deriving a column from other columns without a round trip to a data source.
AI
Generated values. An AI prompt runs per row, optionally using a specific tool (web research, data analysis, conversation intelligence, email drafting) and optionally grounded in other columns or in ZoomInfo knowledge. This is how you add a research column that reads like a human did the work.
The pairing of ZOOMINFO_MATCH and AI columns is what makes the API more than a bulk enrichment tool. The match column anchors a row to a known company or contact. An AI column can then take that grounding as a data dependency and generate something specific: a one-line account summary, a hypothesis about why this account fits your ICP, a draft opening line for outreach. The research is grounded in real data rather than guessed from a name.
Data types are separate from column types
A column's columnType says where values come from. Its dataType (TEXT, EMAIL, INTEGER, DATE, ZI_CONTACT_ID, and roughly twenty others) says what shape the value takes. A ZOOMINFO_MATCH column only accepts ZI_CONTACT_ID or ZI_COMPANY_ID, since its job is to hold a resolved identifier.
Two ways to build
Everything above (rows, columns, folders, the four column types) holds on both paths. What differs is the interface, and how much of the lifecycle each one covers today.
In conversation, over MCP
Describe the audience to an AI agent and three MCP tools set it up: Upsert Audience creates it, Manage Audience Columns defines its structure, and Manage Audience Rows loads records. Setup is the tedious part of a spreadsheet interface, and it goes much faster described out loud. Enrichment then runs in ZoomInfo. Once it lands, Audience Analysis answers questions about the result in the same conversation.
In code, over the REST API
The full lifecycle from your own code: create, load, set match criteria, run enrichment, poll the job, and read enriched rows back. This is the path for pipelines, scheduled runs, and headless agents.
Building over MCP
The MCP tools set up audiences; enrichment runs in ZoomInfo. You can describe a set of accounts, ingest them, and define the AI columns you want against them, all in conversation, in a handful of prompts. Then you open the audience in ZoomInfo to run those columns and carry on into scoring, views, and activation. Running enrichment from the agent is on the roadmap.
That division plays to what each side is good at. Naming things, pasting criteria, and writing prompts into config panels go faster in conversation. The platform is a full workbench, and a 4,000-row enrichment is a job you want to watch run.
The chain
The three tools run in sequence, linked by IDs:
Upsert Audience → audienceId
↓
Manage Audience Columns → columnId
↓
Manage Audience Rows (needs audienceId + columnId)No ID can be invented. Every one comes from a prior call: Browse Audiences, Get Audience, or the create response. When working with something that already exists, the agent looks it up first.
Two ways records get in
Ingestion runs over MCP, and it is charged. The path determines the row limit and the cost.
| ZoomInfo search | Your own records | |
|---|---|---|
| How | Search Companies or Search Contacts, then Upsert Audience with searchQuery | Upsert Audience, then Manage Audience Rows |
| Row limit | None; runs as a job | 50 per call |
| Charge | 1 data credit per record; Records Under Management applies | Matched rows only; Records Under Management applies |
| Unmatched rows | n/a | Ingest free, fully enrichable |
Both paths check Records Under Management before charging, and both return baseline company or contact fields on every matched row.
A worked example, in four prompts
The target: software companies in Boston that recently moved to AI-based pricing.
- Search. Search Companies with the industry, location, and size filters. Free.
- Create the audience. Upsert Audience with
type: COMPANYand those exact search parameters assearchQuery. The job ingests every match at 1 data credit each, minus anything already under management. Rows arrive with name, industry, employee range, revenue range, HQ country, and ZoomInfo ID. - Define the research column. Manage Audience Columns, one AI column with the web research capability: "Has this company changed its pricing model in the last 12 months? If so, describe the change in one sentence and note whether it is usage-based or AI-based."
- Define a score. A second column using data analysis: "Based on the pricing change column and the employee range, score this account High, Medium, or Low as a displacement target." No external lookup; it reasons over columns already present.
Then, in ZoomInfo: run the columns across the audience, review the output, and continue into filtering, scoring, and activation. Once the columns have run, Audience Analysis can interrogate the result back in conversation: the score distribution, the fill rate on the research column, and which accounts scored High and why. Four prompts gets you a populated, configured audience. The same shape works when step 1 is a CSV instead of a search; everything from step 3 onward is identical, including on rows that never matched.
What to watch for
Defining a column doesn't run it. Columns created over MCP are configured, waiting for execution. Open the audience in ZoomInfo to run them. If you define four columns and the cells stay empty, that's why; nothing is broken.
The empty audience. If a search ran and searchQuery wasn't passed through, the audience is created empty and nothing errors. If a prospecting audience comes back with no rows, this is why. It's the most common failure on these tools.
Immutable fields fail silently. Audience type and column dataType can't change after creation. Passing them on update is ignored rather than rejected, with no error to tell you it didn't take. To change either, create new.
Vague prompts produce vague columns. An AI column is only as good as its instruction. State what the cell should contain and which capability produces it.
Conversation intelligence needs first-party data. Those columns only return anything for accounts your team has talked to. They also need conversation intelligence connected at the tenant level. Expect blanks on cold prospecting audiences; that's correct behavior, not a failure.
Batch your own loads. 50 rows per call, so a 400-row list is eight calls.
Limits over MCP
An audience supports up to 40 million cells: hundreds of columns across hundreds of thousands of rows, or a few columns across millions. The ceiling is the product of the two. Your real constraint is usually the credit allocation your admin has set per user in Admin Portal, and those limits apply over MCP.
Building over the API
Everything from here to the endpoint reference is the REST path: authentication, the build lifecycle, a complete worked example with curl, filtering and sorting, formula and AI columns, folders, and jobs. Unlike MCP today, this path covers the full lifecycle, including running enrichment and reading enriched rows back.
Authentication
The Audiences API uses OAuth2 client credentials, the same flow as the rest of the ZoomInfo REST API. Mint a token, then send it as a bearer token on every request.
TOKEN=$(curl -s -X POST https://api.zoominfo.com/gtm/oauth/v1/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json" \
-H "User-Agent: gtm-studio-quickstart/1.0" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=$ZOOMINFO_CLIENT_ID" \
--data-urlencode "client_secret=$ZOOMINFO_CLIENT_SECRET" \
| jq -r '.access_token')Three things to know before you make a single call:
- Base URL. Everything in this guide lives under
https://api.zoominfo.com/gtm/studio/v1. - Content type. Requests and responses use JSON:API, so set both
Content-TypeandAccepttoapplication/vnd.api+json. Every payload is wrapped in a top-leveldataobject withtypeandattributes. - Scopes. Reading audiences requires
api:audience:read. Creating or changing anything requiresapi:audience:manage.
Set a User-Agent
Always send an explicit User-Agent header. Default standard-library user agents are frequently blocked at the gateway and return a misleading 403. The access_token is an opaque string; copy it verbatim into Authorization: Bearer <token> without decoding or trimming it.
The build lifecycle
Building an enriched audience follows a consistent path. Steps three and four are interchangeable, and you will revisit five through seven every time the audience grows.
Create a folder (optional)
Group the audience with related ones. Skip this and the API creates a folder named after the audience.
Create the audience
Choose CONTACT or COMPANY and, optionally, define your starting columns inline.
Add columns
Define the rest of the fields: the data you will upload, the match column that grounds rows, and any formula or AI columns.
Set match criteria
Tell enrichment how to resolve rows by mapping your input columns to ZoomInfo attributes (an Email column to CONTACT_EMAIL, a domain column to COMPANY_WEBSITE).
Upsert rows
Write your seed data. Each row is a set of cells keyed by column id.
Enrich
Kick off an asynchronous job that matches rows against ZoomInfo and populates match and AI columns.
Poll and read
Watch the job to completion, then read rows back with filters, sorting, and pagination.
A complete worked example
The scenario: you came back from a conference with a list of names, companies, and email addresses. The data is thin. You want to resolve each person to their ZoomInfo profile and generate a short account note, grounded in real data, for the rep who follows up.
1. Create the audience with its input columns
Create a CONTACT audience and define three CUSTOM columns inline. Setting autoMatchCriteria=true lets ZoomInfo infer the column-to-attribute mapping for you, so the Email column maps to CONTACT_EMAIL without a separate call.
curl -s -X POST "https://api.zoominfo.com/gtm/studio/v1/audiences?autoMatchCriteria=true" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-H "User-Agent: gtm-studio-quickstart/1.0" \
-d '{
"data": {
"type": "Audience",
"attributes": {
"name": "SaaStr 2026 Booth Scans",
"type": "CONTACT",
"origin": "CUSTOM",
"columns": [
{ "type": "Column", "attributes": { "columnType": "CUSTOM", "name": "Email", "dataType": "EMAIL" } },
{ "type": "Column", "attributes": { "columnType": "CUSTOM", "name": "Full Name", "dataType": "TEXT" } },
{ "type": "Column", "attributes": { "columnType": "CUSTOM", "name": "Company", "dataType": "TEXT" } }
]
}
}
}'The 201 response returns the audience with a system-assigned id and a columnId for each column. Hold on to those; every later call references them.
2. Add a grounding column and a research column
Now add the two columns that do the work. A ZOOMINFO_MATCH column resolves each row to a ZoomInfo contact, and an AI column writes a follow-up note grounded in ZoomInfo's data about that contact's company.
curl -s -X POST "https://api.zoominfo.com/gtm/studio/v1/audiences/$AUDIENCE_ID/columns/actions/bulk/create" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-H "User-Agent: gtm-studio-quickstart/1.0" \
-d '{
"data": [
{
"type": "Column",
"attributes": {
"columnType": "ZOOMINFO_MATCH",
"name": "ZoomInfo Contact",
"dataType": "ZI_CONTACT_ID"
}
},
{
"type": "Column",
"attributes": {
"columnType": "AI",
"name": "Follow-up Angle",
"tool": "AI_WEB_RESEARCH",
"prompt": "In one sentence, suggest a relevant opening angle for a sales follow-up with this contact based on their company and recent activity."
}
}
]
}'3. Confirm the match criteria
With autoMatchCriteria=true at creation, the Email column already maps to CONTACT_EMAIL. To set or override mappings explicitly, post to the match-criteria endpoint with the column id you want to map.
curl -s -X POST "https://api.zoominfo.com/gtm/studio/v1/audiences/$AUDIENCE_ID/actions/match-criteria" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-H "User-Agent: gtm-studio-quickstart/1.0" \
-d '{
"data": {
"type": "UpsertMatchCriteria",
"attributes": {
"matchCriteria": [
{ "columnId": "'$EMAIL_COLUMN_ID'", "mappedTo": "CONTACT_EMAIL" }
]
}
}
}'Send the request with no body and the system auto-maps every eligible column with AI, and creates the match column if one does not exist. The explicit form above is worth using when you want deterministic mappings.
4. Upsert your seed rows
Write the data you collected. Omit a row id to create, include one to update, and give each cell its columnId and a value.
curl -s -X POST "https://api.zoominfo.com/gtm/studio/v1/audiences/$AUDIENCE_ID/rows/actions/bulk/upsert" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-H "User-Agent: gtm-studio-quickstart/1.0" \
-d '{
"data": [
{
"type": "Row",
"attributes": {
"values": [
{ "columnId": "'$EMAIL_COLUMN_ID'", "value": "dana@northwind.io" },
{ "columnId": "'$NAME_COLUMN_ID'", "value": "Dana Lin" },
{ "columnId": "'$COMPANY_COLUMN_ID'", "value": "Northwind" }
]
}
},
{
"type": "Row",
"attributes": {
"values": [
{ "columnId": "'$EMAIL_COLUMN_ID'", "value": "marco@brightloom.com" },
{ "columnId": "'$NAME_COLUMN_ID'", "value": "Marco Reyes" },
{ "columnId": "'$COMPANY_COLUMN_ID'", "value": "Brightloom" }
]
}
}
]
}'A single upsert call accepts up to 500 rows. To enrich the moment rows land, add ?runEnrichment=true and skip the next step, though keeping them separate is cleaner when you want to inspect the data first.
5. Run enrichment
Enrichment is asynchronous. Scope it to the whole audience, or to specific rows with scope: "ROW" and a list of rowId values. The response is a 202 carrying a job id.
curl -s -X POST "https://api.zoominfo.com/gtm/studio/v1/audiences/$AUDIENCE_ID/actions/enrich" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-H "User-Agent: gtm-studio-quickstart/1.0" \
-d '{
"data": {
"type": "AudienceEnrichment",
"attributes": { "scope": "AUDIENCE" }
}
}'6. Poll the job
Take the id from the enrichment response and poll until the status reaches a terminal state.
curl -s "https://api.zoominfo.com/gtm/studio/v1/audiences/$AUDIENCE_ID/jobs/$JOB_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.api+json" \
-H "User-Agent: gtm-studio-quickstart/1.0"The response carries a status and a percentProgress. Statuses run SCHEDULED, RUNNING, then one of SUCCEEDED, PARTIALLY_SUCCEEDED, FAILED, or CANCELLED. Poll on a few-second interval and stop once you leave the running states. Watch for the partial case. The job finished, but some rows did not resolve, and the per-cell detail in the next step tells you which ones.
7. Read the enriched rows
List rows back. Each cell now reports both a value and a state, so you can tell a real result from a blank or a miss.
curl -s -X POST "https://api.zoominfo.com/gtm/studio/v1/audiences/$AUDIENCE_ID/rows/actions/search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-H "User-Agent: gtm-studio-quickstart/1.0" \
-d '{
"data": {
"type": "RowSearch",
"attributes": {}
}
}'What started as a name and an email is now a resolved ZoomInfo contact with a grounded follow-up note attached to every row that matched.
Reading, filtering, and sorting rows
The List Rows endpoint (POST .../rows/actions/search) is the read path for everything in an audience, with pagination and sorting on query parameters and filtering in the body.
Filters are a group of conditions combined with AND or OR. Each condition names a columnId, a filterOperator, and the values to compare against.
{
"data": {
"type": "RowSearch",
"attributes": {
"filter": {
"operator": "AND",
"filters": [
{ "type": "Filter", "columnId": "<industry-col>", "filterOperator": "EQUALS", "values": ["Software"] },
{ "type": "Filter", "columnId": "<employees-col>", "filterOperator": "GREATER_THAN", "values": [500] }
]
}
}
}
}The operator set is broad: equality and containment (EQUALS, CONTAINS, STARTS_WITH), numeric and date comparisons (GREATER_THAN, IN_RANGE, BEFORE, WITHIN), list membership (HAS_ONE_OF, HAS_ALL_OF), and presence checks (EMPTY, NOT_EMPTY). Not every operator is valid on every column.
Before building a filter, ask the audience which operators each column supports.
Validate filters before you send them
Call GET .../filter-metadata to get, per column, the list of allowed operators and their constraints: whether multiple values are accepted, the maximum value count, and the minimum character length for string operators. Validating against this metadata client-side turns a class of runtime 400s into checks you can make before the request leaves your machine.
To page through results, use page[number] and page[size]; rows allow up to 500 per page. Sort with the sort query parameter set to a columnId, with a - prefix for descending order. Narrow the response with the columns parameter. Or skip the search entirely: GET .../rows/{rowId} fetches one record by id.
Cell states
Every cell reports a state alongside its value, and reading it is how you tell signal from silence.
| State | Meaning |
|---|---|
RESULT | A value was retrieved, and the cell holds valid data |
BLANK | No value set, and enrichment has not been attempted |
LOADING | Enrichment is in progress and the value is not ready yet |
NO_RESULT | Enrichment ran and ZoomInfo found no data for this cell |
ERROR | Enrichment failed, and errorDetails explains why and whether a retry is likely to succeed |
Misses and failures differ. A NO_RESULT is definitive: ZoomInfo has no data here, and retrying will not change that. An ERROR carries a retryable flag, and when it is true, running enrichment again on that row is worth doing.
Formula and AI columns
CUSTOM and ZOOMINFO_MATCH columns cover input and grounding. The other two types are where an audience starts doing analysis on its own.
A formula column takes a prompt and compiles it to a JavaScript expression evaluated per row. Use it for derived fields that need no external data: concatenating a full name, bucketing employee counts into segments, computing a ratio between two numeric columns. Nothing leaves the row.
An AI column runs a prompt per row, optionally backed by one of four tools. Web research (AI_WEB_RESEARCH) pulls current web information, data analysis (AI_DATA_ANALYSIS) reasons over the row's own data, conversation intelligence (AI_CONVERSATION_INTELLIGENCE) draws on call signals, and email drafting (AI_EMAILER) composes outreach.
AI columns get sharper when you give them context. The dataDependencies field accepts two kinds of input: a COLUMN, which feeds another column's value into the prompt, and a KNOWLEDGE_BASE, which draws on account intelligence and ZoomInfo data. To discover what a given tool can use as context, call GET .../columns/data-dependencies/{tool}. It returns the valid dependency sources for that tool, with their identifiers and data types, so you can construct a column that is grounded rather than guessing.
That discovery step is what makes AI columns reliable enough to build on. You are not hoping the model knows about an account. You are handing it the resolved record and telling it which fields to reason from.
Working with folders
Folders organize audiences and are the unit you list against when you do not already have an audience id. Create one with POST /folders, optionally starred: true for quick access, and list them with GET /folders, filtering by creator, search text, or date range and sorting by name, audience count, or recency.
Assign an audience to a folder at creation with folderId, or move it later with PATCH /audiences/{id}. The same patch endpoint renames an audience and edits its description and notes. It touches only what you send.
Asynchronous jobs
Two operations run as background jobs rather than returning their result inline: enriching an audience and bulk-deleting rows. Each returns a job id, and each is tracked through the same GET .../jobs/{jobId} endpoint, which reports status across all job types. The pattern is always the same: kick off the operation, take the id from the 202, and poll to a terminal state before you read the result. Build a small poller once and reuse it everywhere.
Building for agents
This API was designed so that an agent can operate it without a human in the loop. The shape of the contract makes that practical. Resources carry stable ids. Operations are explicit rather than inferred. Long-running work returns a job to poll instead of a connection to hold open. An agent can create an audience, discover which context a research column is allowed to use, then write that column. It enriches, polls, and reads the result from the same flat set of endpoints a person would use.
The grounding is what makes agentic use trustworthy. A ZOOMINFO_MATCH column ties each row to a real entity before any AI column reasons about it, so generated content rests on ZoomInfo's data rather than on a model's recollection of a company name. The data-dependencies endpoint lets an agent check what it is allowed to ground a column in before it commits to a prompt. Enrichment reports per-cell state, so an agent can tell a genuine miss from a retryable error and act accordingly.
Two doors, one room. Inside Claude or any MCP client, Upsert Audience, Manage Audience Columns, and Manage Audience Rows cover setup in plain language. The REST API is the headless door: code building a dataset that persists, enriches, and grows. Both end at an audience that lives in ZoomInfo and is ready to act on.
Getting started
The fastest first run is conversational. Connect the ZoomInfo MCP server, ask for a small audience built from a search, and open it in ZoomInfo.
For the API path you need a ZoomInfo subscription with API access and a client id and secret carrying the api:audience:read and api:audience:manage scopes. Set them as environment variables, mint a token, and run the worked example end to end. Start with a CONTACT audience and a handful of rows, watch a single enrichment job through to SUCCEEDED, and read the cell states back. Once that loop is familiar, the rest of the API is variations on it. More columns, richer filters, larger batches. The payoff is AI columns that turn a list of names into something a rep can act on the morning it lands.
Endpoint reference
The surface area below is indicative, grouped by resource so you can see the shape of the API at a glance. For the authoritative, always-current reference, with full request and response schemas, see the ZoomInfo API reference↗. Every path is relative to https://api.zoominfo.com/gtm/studio/v1.
- Folders create, list, fetch, update, and delete the folders that organize audiences.
- Audiences create (optionally with columns), list and filter, fetch with full column structure, patch metadata, and delete.
- Columns bulk-create columns of any type, discover the context sources an AI tool is allowed to use, and update or delete a column.
- Rows fetch a single row, search with filters and sorting, bulk-upsert, and bulk-delete.
- Jobs poll the status of any asynchronous operation, including audience creation, enrichment, and bulk deletes.