Building an Audience Builder UI

flowchart LR
    A["Field Catalog API"] -->|field metadata + options| B["Audience Builder UI"]
    B -->|filter rules| C["Audiences API"]
    C -->|audience ID| D["Poll / Preview / Download"]

    style A fill:#3b82f6,color:#fff
    style B fill:#6366f1,color:#fff
    style C fill:#22c55e,color:#fff

Use Case

  • Build a dynamic audience builder UI that renders the right controls for each field
  • Fetch field metadata and picklist options from the API instead of hardcoding them
  • Submit filter rules to the Audiences API to create, preview, and download audiences

Prerequisites


Steps

1. Fetch Field Metadata

Load all audience fields with their options when the builder initializes.

GET https://api.delivr.ai/api/v1/field-catalog?schema_type=audience&status=active&include_options=true
Authorization: Bearer <jwt>

Response shape:

{
  "total_count": 42,
  "fields": [
    {
      "field_key": "seniority_level",
      "display_name": "Seniority Level",
      "category": "contact",
      "field_type": "multiselect",
      "operators": ["in", "notin"],
      "options": [
        { "value": "cxo", "label": "CXO" },
        { "value": "director", "label": "Director" },
        { "value": "manager", "label": "Manager" },
        { "value": "staff", "label": "Staff" },
        { "value": "vp", "label": "VP" }
      ],
      "sort_order": 12
    }
  ]
}

See Field Catalog API for the complete field object schema.

The field catalog changes infrequently. Cache the response for the session or use SWR/React Query with a long staleTime.

2. Group and Display Fields

Group fields by category into sidebar sections:

CategoryLabelDescription
contactContactJob title, seniority, department, email status
companyCompanyIndustry, employee count, revenue, location
demographicsDemographicsAge, gender, income, homeowner
intentIntentScore, topic (handled separately)

Sort by sort_order within each group. Lower numbers appear first.

interface FieldCatalogEntry {
  field_key: string;
  display_name: string;
  category: string;
  field_type: "multiselect" | "select" | "text" | "number";
  operators: string[];
  options?: { value: string; label: string }[];
  sort_order: number;
}

function groupByCategory(fields: FieldCatalogEntry[]) {
  const groups: Record<string, FieldCatalogEntry[]> = {};
  for (const field of fields) {
    if (!groups[field.category]) groups[field.category] = [];
    groups[field.category].push(field);
  }
  for (const cat of Object.keys(groups)) {
    groups[cat].sort((a, b) => a.sort_order - b.sort_order);
  }
  return groups;
}

3. Render the Right Control for Each Field

The field_type property tells you what UI control to render:

field_typeControlData Source
multiselectMulti-select dropdown (chips)options[] from API
selectSingle-select dropdownoptions[] from API
textFree-text inputUser types a value
numberNumeric inputUser types a number

For multiselect fields, show options[].label to the user and store options[].value for submission:

function renderMultiselect(field: FieldCatalogEntry) {
  return (
    <MultiSelect
      label={field.display_name}
      options={field.options.map(opt => ({
        value: opt.value,   // "cxo" - sent in filter rule
        label: opt.label,   // "CXO" - shown in UI
      }))}
      onChange={(selected) => updateRule(field.field_key, "in", selected)}
    />
  );
}

For fields with many options, use a searchable typeahead:

FieldOption CountRecommended Control
company_industry346Searchable dropdown (client-side filter)
job_title_normalized16,077Async search / typeahead (do not load into a static dropdown)
Most other multiselect fields2-26Standard multi-select dropdown

4. Show Valid Operators

Each field declares its valid operators array. Map these to user-friendly labels:

API OperatorDisplay LabelUsed With
in"is any of"multiselect, select
notin"is none of"multiselect, select
is"equals"text, select
is not"does not equal"text, select
contains"contains"text
notcontains"does not contain"text
startsWith"starts with"text
endsWith"ends with"text
notnull"has a value"any (no value input needed)
isnull"is blank"any (no value input needed)
>"greater than"number
>="at least"number
<"less than"number
<="at most"number

Only show operators from the field's operators array. When the user selects notnull or isnull, hide the value input since these operators take no value.

5. Build the Filter Tree

The Audiences API expects a filter tree with condition (AND/OR logic) and rules (individual conditions or nested groups).

{
  "filter": {
    "condition": "and",
    "rules": [
      {
        "fieldName": "INTENT",
        "conditionRules": {
          "operator": "in",
          "value": ["topic_id_1", "topic_id_2"]
        }
      },
      {
        "fieldName": "seniority_level",
        "conditionRules": {
          "operator": "in",
          "value": ["cxo", "vp", "director"]
        }
      }
    ]
  }
}

Every rule has the same shape:

PropertyTypeDescription
fieldNamestringThe field_key from the catalog, or a special field (INTENT, PERSONA, ACCOUNT, score)
conditionRules.operatorstringOne of the operators from the field's operators array
conditionRules.valuestring or string[]For in/notin: array. For is/contains/etc.: string. For notnull/isnull: omit or "".

Value rules:

  • Always use options[].value (lowercase), never label
  • For in/notin, value must be an array: ["cxo", "vp"]
  • For is/contains/startsWith/endsWith, value is a single string: "software"
  • For numeric operators, value is a string representation: "500"

To combine rules with mixed logic, nest groups:

{
  "condition": "and",
  "rules": [
    {
      "fieldName": "INTENT",
      "conditionRules": { "operator": "in", "value": ["topic_123"] }
    },
    {
      "fieldName": "seniority_level",
      "conditionRules": { "operator": "in", "value": ["cxo", "vp", "director"] }
    },
    {
      "condition": "or",
      "rules": [
        {
          "fieldName": "company_industry",
          "conditionRules": { "operator": "contains", "value": "software" }
        },
        {
          "fieldName": "company_industry",
          "conditionRules": { "operator": "contains", "value": "internet" }
        }
      ]
    }
  ]
}

This means: INTENT matches AND seniority is VP+ AND (industry contains software OR industry contains internet).

6. Handle Special Fields

Some fields are not in the Field Catalog and are handled separately.

INTENT (required): Every audience must include at least one INTENT rule. Topics come from the Taxonomy API. Maximum 10 topics.

{
  "fieldName": "INTENT",
  "conditionRules": { "operator": "in", "value": ["4eyes_115481", "4eyes_119418"] }
}

Score: Filter by intent signal strength. Values: "high", "medium", "low".

{
  "fieldName": "score",
  "conditionRules": { "operator": "in", "value": ["high", "medium"] }
}

PERSONA / ACCOUNT: Reference other saved audiences to layer targeting.

{
  "fieldName": "PERSONA",
  "conditionRules": { "operator": "in", "value": ["4738"] }
}

7. Submit to the Audiences API

POST https://apiv3.delivr.ai/api/v1/audiences?project_id={project_id}
Authorization: Bearer <jwt>
Content-Type: application/json
{
  "organization_id": "org_123",
  "project_id": "proj_456",
  "audience_name": "Enterprise VPs - Cloud Computing",
  "type": "intents",
  "segmentation_type": "Audience",
  "filter": {
    "condition": "and",
    "rules": [
      {
        "fieldName": "INTENT",
        "conditionRules": { "operator": "in", "value": ["4eyes_119418"] }
      },
      {
        "fieldName": "seniority_level",
        "conditionRules": { "operator": "in", "value": ["cxo", "vp", "director"] }
      },
      {
        "fieldName": "company_employee_count_range",
        "conditionRules": { "operator": "in", "value": ["1001 to 5000", "5001 to 10000", "10000+"] }
      }
    ]
  }
}

Required fields:

FieldTypeDescription
organization_idstringMaster organization ID
project_idstringProject (workspace) ID
audience_namestringDisplay name for this audience
typestring"intents" for intent-based audiences
segmentation_typestring"Audience", "Persona", or "Account"
filterobjectThe filter tree

Response:

{
  "id": 8269,
  "status": "Pending",
  "audience_name": "Enterprise VPs - Cloud Computing",
  "type": "intents",
  "segmentation_type": "Audience"
}

8. Poll, Preview, and Download

After submitting an audience, show the user its processing status. There are two polling phases: audience processing and file preparation.

Audience Processing Status

Poll GET /api/v1/audiences/{id}?project_id={project_id} until the audience reaches a terminal state.

StatusUI StateDescription
PendingSpinner / "Queued"Audience is queued for processing
SyncingProgress bar / "Processing"Audience is being built
CompletedSuccess / show sizeAudience is ready -- display size (contact count)
FailedError stateShow the error field from the response

Poll every 10 seconds. Typical processing time is 30-120 seconds.

async function pollAudienceStatus(token: string, audienceId: number, projectId: string) {
  while (true) {
    const resp = await fetch(
      `https://apiv3.delivr.ai/api/v1/audiences/${audienceId}?project_id=${projectId}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    const data = await resp.json();

    if (data.status === "Completed") return data;
    if (data.status === "Failed") throw new Error(data.error || "Audience processing failed");

    await new Promise((r) => setTimeout(r, 10_000));
  }
}

File Status

Once the audience is Completed, check whether output files are ready before showing a download button.

Poll GET /api/v1/audiences/{id}/status?project_id={project_id} for a lightweight readiness check (no side effects).

StatusUI StateDescription
readyDownload button enabledFiles exist with valid URLs
unloadingSpinner / "Preparing files"Files are being generated
no_taskTrigger downloadAudience has never been exported -- call the download endpoint to start
failedError stateFile preparation failed

Poll every 3 seconds. Timeout after 3 minutes.

async function pollFileStatus(token: string, audienceId: number, projectId: string) {
  for (let i = 0; i < 60; i++) {
    const resp = await fetch(
      `https://apiv3.delivr.ai/api/v1/audiences/${audienceId}/status?project_id=${projectId}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    const data = await resp.json();

    if (data.status === "ready") return true;
    if (data.status === "failed") throw new Error("File preparation failed");
    if (data.status === "no_task") {
      // Trigger file preparation by calling the download endpoint once
      await fetch(
        `https://apiv3.delivr.ai/api/v1/audiences/${audienceId}/download?project_id=${projectId}`,
        { headers: { Authorization: `Bearer ${token}` } }
      );
    }

    await new Promise((r) => setTimeout(r, 3_000));
  }
  throw new Error("File preparation timed out");
}

Download

Once the status endpoint returns ready, call the download endpoint to get signed URLs:

GET /api/v1/audiences/{id}/download?project_id={project_id} returns output_links with signed Parquet URLs valid for 24 hours.


Preview

Once the audience is Completed, fetch a sample without waiting for the full download.

GET /api/v1/audiences/{id}/sample?project_id={project_id} returns up to 200 rows with ~88 fields per contact. Use this to show a preview table while files are still being prepared.

See Audience End-to-End for the complete poll/preview/download flow with Python examples.


Complete Example

interface FieldOption { value: string; label: string }

interface CatalogField {
  field_key: string;
  display_name: string;
  category: string;
  field_type: "multiselect" | "select" | "text" | "number";
  operators: string[];
  options?: FieldOption[];
  sort_order: number;
}

interface FilterRule {
  fieldName: string;
  conditionRules: { operator: string; value: string | string[] };
}

interface FilterGroup {
  condition: "and" | "or";
  rules: (FilterRule | FilterGroup)[];
}

// 1. Fetch field catalog
async function fetchFields(token: string): Promise<CatalogField[]> {
  const resp = await fetch(
    "https://api.delivr.ai/api/v1/field-catalog?schema_type=audience&status=active&include_options=true",
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const data = await resp.json();
  return data.fields;
}

// 2. Build a filter rule from UI state
function buildRule(fieldKey: string, operator: string, value: string | string[]): FilterRule {
  return { fieldName: fieldKey, conditionRules: { operator, value } };
}

// 3. Assemble the filter tree
function buildFilter(
  intentTopicIds: string[],
  userRules: FilterRule[],
  scoreFilter?: string[]
): FilterGroup {
  const rules: (FilterRule | FilterGroup)[] = [
    buildRule("INTENT", "in", intentTopicIds),
  ];
  if (scoreFilter?.length) {
    rules.push(buildRule("score", "in", scoreFilter));
  }
  rules.push(...userRules);
  return { condition: "and", rules };
}

// 4. Submit to Audiences API
async function createAudience(
  token: string, orgId: string, projectId: string,
  name: string, filter: FilterGroup
) {
  const resp = await fetch(
    `https://apiv3.delivr.ai/api/v1/audiences?project_id=${projectId}`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        organization_id: orgId,
        project_id: projectId,
        audience_name: name,
        type: "intents",
        segmentation_type: "Audience",
        filter,
      }),
    }
  );
  return resp.json();
}

// Usage
const fields = await fetchFields(token);
const contactFields = fields
  .filter(f => f.category === "contact")
  .sort((a, b) => a.sort_order - b.sort_order);

const seniorityRule = buildRule("seniority_level", "in", ["cxo", "vp"]);
const deptRule = buildRule("department", "in", ["engineering", "sales"]);

const filter = buildFilter(
  ["4eyes_119418", "4eyes_115481"],
  [seniorityRule, deptRule],
  ["high", "medium"]
);

const audience = await createAudience(token, orgId, projectId, "My Audience", filter);
// audience.id = 8269, audience.status = "Pending"

Notes

  • The Field Catalog is on api.delivr.ai. The Audiences API is on apiv3.delivr.ai. The Taxonomy API is on apiv2.delivr.ai.
  • Always use options[].value (lowercase) in filter rules, not options[].label. The backend matches on lowercase values from the parquet data.
  • For in/notin operators, value must be an array (["cxo"]), not a string ("cxo").
  • Audiences of type intents require at least one INTENT rule. Persona and Account types do not.
  • job_title_normalized has 16,077 options. Use a searchable typeahead, not a static dropdown.
  • Fetch options from the API instead of hardcoding them. The field catalog is updated when new values are added to the data.
  • The audiences/schema endpoint (GET /api/v1/audiences/schema?project_id={project_id}) returns field definitions from the audience builder's perspective. Use it alongside the Field Catalog to cross-reference available fields.

Next Steps