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

**Date:** 2026-06-06  
**Source:** https://gtm.ai/guides/gtm-studio-audiences-api

---

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 `400`s 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 |

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](/guides/how-to-audience-building-in-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](https://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.