← Guides
api

Building Audiences with the GTM Studio API

A complete guide to ZoomInfo's GTM Studio Audiences API: create durable audience datasets, ground every row in ZoomInfo's identity graph, add AI research columns, and enrich at scale from your own code or an agent.

Jagannath RameshJagannath RameshPrincipal Product Manager··17 min read

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 GTM Studio 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 the whole API. You will learn the audience data model and the four column types that make it programmable. You will see the exact sequence of calls that takes a raw list of emails and turns it into an enriched, researched dataset. Every endpoint is documented, 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.

Authentication

The GTM Studio 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.

bash
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-Type and Accept to application/vnd.api+json. Every payload is wrapped in a top-level data object with type and attributes.
  • Scopes. Reading audiences requires api:audience:read. Creating or changing anything requires api: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.

bash
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.

bash
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.

bash
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. Each cell names its columnId and a value.

bash
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. 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.

bash
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.

bash
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.

bash
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. Pagination and sorting ride on query parameters; filtering lives 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.

json
{
  "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.

StateMeaning
RESULTA value was retrieved, and the cell holds valid data
BLANKNo value set, and enrichment has not been attempted
LOADINGEnrichment is in progress and the value is not ready yet
NO_RESULTEnrichment ran and ZoomInfo found no data for this cell
ERROREnrichment failed, and errorDetails explains why and whether a retry is likely to succeed

The distinction between a miss and a failure matters. 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.

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 setting starred: true for quick access. List them with GET /folders, which supports filtering by creator, search text, and date ranges, 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 the fields 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.

The conversational audience-building you can already do inside Claude and the programmatic control described here are two doors into the same room. One is a person describing what they want in plain language. The other is 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

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 developer docs at docs.zoominfo.com. 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.