Create an Intent Audience (End-to-End)

Browse topics, create audience, poll status, preview, and download

flowchart LR
    A[Find Topics] --> B[Create Audience]
    B --> C[Poll Until Complete]
    C --> D[Preview Sample]
    D --> E[Download Parquet]

    style A fill:#3b82f6,color:#fff
    style E fill:#22c55e,color:#fff

Use Case

  • Create a targeted audience from intent topics and download the results
  • End-to-end workflow: browse topics, create audience, poll status, preview, download
  • Automate audience creation for recurring campaigns

Prerequisites

  • Organization API key + secret pair (Authentication). Create one at https://app.delivr.ai/{org_id}/settings/api-keys.
  • A project ID (Account Setup)

Steps

1. Find Topics

Browse the taxonomy to find topics relevant to your campaign. The Taxonomy API is on api.delivr.ai.

import requests

API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
HEADERS = {
    "X-Api-Key": API_KEY,
    "X-Api-Secret": API_SECRET,
}

# Search for topics by name
resp = requests.get(
    "https://api.delivr.ai/api/v1/taxonomy/topics",
    headers=HEADERS,
    params={"search": "cloud computing", "limit": 10},
)
topics = resp.json()["response"]["topics"]
for t in topics:
    print(f"  {t['topic_id']}: {t['name']} ({t['topic_type']})")

Example output:

  4eyes_119418: Cloud Computing (B2B)
  4eyes_112199: Alibaba Cloud (B2B)
  4eyes_118710: Amazon Elastic Compute Cloud (B2B)

Save the topic_id values you want to target.

2. Create the Audience

import requests

API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
ORG_ID = "your_organization_id"
PROJECT_ID = "your_project_id"

HEADERS = {
    "X-Api-Key": API_KEY,
    "X-Api-Secret": API_SECRET,
    "Content-Type": "application/json",
}

TOPIC_IDS = ["4eyes_119418", "4eyes_112199"]

resp = requests.post(
    f"https://api.delivr.ai/api/v1/audiences?project_id={PROJECT_ID}",
    headers=HEADERS,
    json={
        "organization_id": ORG_ID,
        "project_id": PROJECT_ID,
        "audience_name": "Cloud Computing Intent - Q1 2026",
        "audience_description": "Contacts researching cloud computing topics",
        "type": "intents",
        "segmentation_type": "Audience",
        "filter": {
            "condition": "and",
            "rules": [
                {
                    "fieldName": "INTENT",
                    "conditionRules": {
                        "operator": "in",
                        "value": TOPIC_IDS,
                    },
                },
                {
                    "fieldName": "score",
                    "conditionRules": {
                        "operator": "in",
                        "value": ["high", "medium"],
                    },
                },
            ],
        },
    },
)

audience = resp.json()
audience_id = audience["id"]
print(f"Created audience {audience_id}, status: {audience['status']}")

Response:

{
  "id": 8269,
  "status": "Pending",
  "audience_name": "Cloud Computing Intent - Q1 2026",
  "type": "intents",
  "segmentation_type": "Audience",
  "...": "additional fields"
}

3. Poll Until Complete

The platform processes your audience in the background. Poll GET /api/v1/audiences/{id} until the build reaches a terminal state.

Treat Completed (or the legacy Validated) as success and Failed as error. Treat every other value - Pending, Syncing, Running, anything unrecognized - as still in progress and keep polling. Keying off "is it terminal?" rather than matching one exact string keeps your loop working if a status is ever renamed.

import time

while True:
    resp = requests.get(
        f"https://api.delivr.ai/api/v1/audiences/{audience_id}",
        headers=HEADERS,
        params={"project_id": PROJECT_ID},
    )
    status_data = resp.json()
    status = status_data["status"]
    size = status_data.get("size")
    print(f"  Status: {status}, size: {size}")

    if status in ("Completed", "Validated"):
        print(f"Audience ready: {size} contacts")
        break
    elif status == "Failed":
        print(f"Error: {status_data.get('error')}")
        break

    time.sleep(10)

Status progression: Pending -> Syncing -> Completed

Typical processing time is 30-120 seconds depending on audience size. Do not hard-code a short client-side timeout that marks the order failed: builds can run several minutes for large audiences. Poll until terminal, allow at least ~20 minutes before giving up, and requeue rather than fail permanently if you hit your own ceiling. Note that distinct_profile_count and size stay null until the build finishes, so a null there is a reliable "not done yet" signal even if status momentarily reads Syncing.

4. Preview a Sample

Once completed, preview sample results before downloading the full dataset.

resp = requests.get(
    f"https://api.delivr.ai/api/v1/audiences/{audience_id}/sample",
    headers=HEADERS,
    params={"project_id": PROJECT_ID},
)
sample = resp.json()

for row in sample["rows"][:5]:
    print(f"  {row.get('first_name')}")
    print(f"    {row.get('job_title')} at {row.get('company_name')}")
    print(f"    Score: {row.get('score')}, Topic: {row.get('topic_name')}")
    print(f"    Has business email: {row.get('has_business_email')}")
    print()

The sample is a capped, redacted teaser (up to 25 rows): firmographics, role, intent fields (score, topic_id, topic_name), first_name, the sha256_lc_hem match key, and has_* flags indicating which contact fields exist. Raw contact data (emails, phones, LinkedIn, personal addresses, full names) is not returned here. The full downloaded records (next step) carry the complete field set.

5. Download the Full Audience

The download endpoint prepares files and returns signed URLs. Poll until status is ready.

Watch the vocabulary. The download status is a separate set of values from the build status in step 3. Here, only ready means the files are downloadable and failed means preparation failed - every other value, including done and completed, means still in progress. Do not reuse the build-status rule (where Completed is terminal) on this field, or you will try to read links before they exist.

import time

RETRYABLE_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504}


class TransientDownloadError(Exception):
    """Every attempt in one burst failed for a reason worth retrying."""


def get_with_retry(url, **kwargs):
    """Back off across a short burst. A non-retryable error raises straight
    through; exhausting the burst raises TransientDownloadError, which the
    polling loop treats as "ask again later" rather than as a failed download.
    """
    for attempt in range(5):
        try:
            resp = requests.get(url, timeout=(5, 30), **kwargs)
            resp.raise_for_status()
            return resp
        except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as error:
            status = getattr(error.response, "status_code", None)
            transient = status in RETRYABLE_STATUS_CODES or isinstance(
                error, (requests.Timeout, requests.ConnectionError)
            )
            if not transient:
                print(f"Download status request failed: {error}")
                raise
            if attempt == 4:
                raise TransientDownloadError(
                    f"{attempt + 1} consecutive retryable failures: {error}"
                ) from error

            delay = min(2**attempt, 10)
            print(f"Transient request failure; retrying in {delay}s: {error}")
            time.sleep(delay)

while True:
    try:
        resp = get_with_retry(
            f"https://api.delivr.ai/api/v1/audiences/{audience_id}/download",
            headers=HEADERS,
            params={"project_id": PROJECT_ID},
        )
    except TransientDownloadError as error:
        # The server being briefly unreachable says nothing about the build,
        # which is still running. Only `ready` and `failed` end this loop.
        print(f"  Download status unavailable; still polling: {error}")
        time.sleep(10)
        continue

    dl = resp.json()
    print(f"  Download status: {dl['status']}")

    if dl["status"] == "ready":
        break
    elif dl["status"] == "failed":
        print("Download failed")
        break

    time.sleep(3)

# Download parquet files
for partition, urls in dl["output_links"].items():
    for url in urls:
        if url.split("?")[0].endswith(".parquet"):
            r = requests.get(url)
            filename = partition.replace("/", "_") + ".parquet"
            with open(filename, "wb") as f:
                f.write(r.content)
            print(f"  Downloaded {filename} ({len(r.content)} bytes)")

Download status progression: unloading -> done -> ready

Signed URLs are presigned for up to 7 days. Download promptly, and if a link ever returns an expired-signature error, call the download endpoint again to get freshly signed URLs.

Tip: Use GET /api/v1/audiences/{id}/status for a lightweight file readiness check that doesn't trigger file preparation. Returns ready, unloading, no_task, or failed. See the Intent Audiences API reference for details.


Complete Script

import time
import requests

API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
ORG_ID = "your_organization_id"
PROJECT_ID = "your_project_id"

HEADERS = {
    "X-Api-Key": API_KEY,
    "X-Api-Secret": API_SECRET,
    "Content-Type": "application/json",
}

RETRYABLE_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504}


class TransientDownloadError(Exception):
    """Every attempt in one burst failed for a reason worth retrying."""


def get_with_retry(url, **kwargs):
    """Back off across a short burst. A non-retryable error raises straight
    through; exhausting the burst raises TransientDownloadError, which the
    polling loop treats as "ask again later" rather than as a failed download.
    """
    for attempt in range(5):
        try:
            resp = requests.get(url, timeout=(5, 30), **kwargs)
            resp.raise_for_status()
            return resp
        except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as error:
            status = getattr(error.response, "status_code", None)
            transient = status in RETRYABLE_STATUS_CODES or isinstance(
                error, (requests.Timeout, requests.ConnectionError)
            )
            if not transient:
                print(f"Download status request failed: {error}")
                raise
            if attempt == 4:
                raise TransientDownloadError(
                    f"{attempt + 1} consecutive retryable failures: {error}"
                ) from error

            delay = min(2**attempt, 10)
            print(f"Transient request failure; retrying in {delay}s: {error}")
            time.sleep(delay)

# 1. Search for topics
resp = requests.get(
    "https://api.delivr.ai/api/v1/taxonomy/topics",
    headers=HEADERS,
    params={"search": "cloud computing", "limit": 5, "topic_type": "B2B"},
)
topics = resp.json()["response"]["topics"]
topic_ids = [t["topic_id"] for t in topics[:2]]
print(f"Using topics: {[t['name'] for t in topics[:2]]}")

# 2. Create audience
resp = requests.post(
    f"https://api.delivr.ai/api/v1/audiences?project_id={PROJECT_ID}",
    headers=HEADERS,
    json={
        "organization_id": ORG_ID,
        "project_id": PROJECT_ID,
        "audience_name": f"Cloud Intent - {int(time.time())}",
        "type": "intents",
        "segmentation_type": "Audience",
        "filter": {
            "condition": "and",
            "rules": [
                {
                    "fieldName": "INTENT",
                    "conditionRules": {"operator": "in", "value": topic_ids},
                }
            ],
        },
    },
)
audience_id = resp.json()["id"]
print(f"Created audience {audience_id}")

# 3. Poll until completed
while True:
    resp = requests.get(
        f"https://api.delivr.ai/api/v1/audiences/{audience_id}",
        headers=HEADERS,
        params={"project_id": PROJECT_ID},
    )
    data = resp.json()
    print(f"  {data['status']} (size: {data.get('size')})")
    if data["status"] in ("Completed", "Validated", "Failed"):
        break
    time.sleep(10)

# 4. Preview
if data["status"] == "Completed":
    resp = requests.get(
        f"https://api.delivr.ai/api/v1/audiences/{audience_id}/sample",
        headers=HEADERS,
        params={"project_id": PROJECT_ID, "limit": 3},
    )
    for row in resp.json()["rows"][:3]:
        print(f"  {row.get('first_name')} {row.get('last_name')} at {row.get('company_name')}")

    # 5. Download. Keep polling until the server reaches a terminal state;
    # large audiences can take longer than a fixed browser timeout.
    while True:
        try:
            resp = get_with_retry(
                f"https://api.delivr.ai/api/v1/audiences/{audience_id}/download",
                headers=HEADERS,
                params={"project_id": PROJECT_ID},
            )
        except TransientDownloadError as error:
            # The server being briefly unreachable says nothing about the
            # build, which is still running. Only `ready` and `failed` end
            # this loop.
            print(f"Download status unavailable; still polling: {error}")
            time.sleep(10)
            continue

        dl = resp.json()
        if dl["status"] == "ready":
            for partition, urls in dl["output_links"].items():
                parquet_urls = [u for u in urls if u.split("?")[0].endswith(".parquet")]
                print(f"  {partition}: {len(parquet_urls)} file(s)")
            break
        if dl["status"] == "failed":
            raise RuntimeError("Download preparation failed")
        stage = dl.get("stage", "preparing")
        progress = dl.get("progress")
        if progress:
            print(f"Download {stage}: {progress['completed']}/{progress['total']} jobs")
        else:
            print(f"Download {stage}: still preparing")
        time.sleep(3)

Notes

  • Maximum 10 topics per audience.
  • Preview vs. unload. The unload field controls what a build writes. preview computes size and stats and a small capped sample; unload writes the full downloadable dataset. If you intend to download the whole audience, create it with "unload": "unload". Either way the build still reaches Completed, so check unload mode (or just drive the download endpoint in step 5, which prepares the full files on demand) rather than assuming Completed means a full dataset is already on disk.
  • The score filter is optional. If omitted, all intent strengths (high, medium, low) are included.
  • segmentation_type must be one of: "Audience", "Persona", "Account".
  • Response shape differs by resource. Taxonomy endpoints wrap their payload in a response envelope (resp.json()["response"]["topics"]), while audience endpoints return the object at the top level (resp.json()["id"], resp.json()["status"]) - there is no response.audience wrapper. Read audience fields directly off the body.
  • The Taxonomy API uses api.delivr.ai. The Audiences API uses api.delivr.ai.
  • Download files are in Parquet format. See Reading Parquet Files for how to convert them to CSV or open them in Excel.

Next Steps


Did this page help you?