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://api.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". See Understanding Segmentation Types.
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://api.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, request the download and keep its real
preparation state visible until output files are ready.

Poll GET /api/v1/audiences/{id}/download?project_id={project_id} after the
user requests a download. This starts preparation when needed and returns a
normalized stage plus job progress when the worker provides it.

StatusUI StateDescription
readyDownload button enabledFiles exist with valid URLs
unloadingStage progressFiles are being generated; show stage and optional progress
convertingStage progressA requested CSV is being generated
Any other valuePreparingKeep polling; legacy in-progress values include done and completed
failedError stateFile preparation failed

Poll every 3 seconds while the view is open. Do not show a timeout error after a
fixed duration: large audiences can take longer than a few minutes. If the user
closes the view, stop the browser requests and explain that preparation
continues in the background. Resume polling when they reopen it.

type DownloadPreparation = {
  status: string;
  // "ready" accompanies the ready response; the other four are preparation
  // stages. Widen with `| (string & {})` if you would rather not update the
  // union when a stage is added.
  stage?: "ready" | "waiting" | "building" | "deduplicating" | "finalizing";
  progress?: { completed: number; total: number };
  output_links?: Record<string, string[]>;
};

const RETRYABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);

class RetryableRequestError extends Error {}

function wait(ms: number, signal: AbortSignal) {
  if (signal.aborted) {
    return Promise.reject(
      signal.reason ?? new DOMException("Polling stopped", "AbortError"),
    );
  }

  return new Promise<void>((resolve, reject) => {
    const timer = setTimeout(finish, ms);

    function finish() {
      signal.removeEventListener("abort", cancel);
      resolve();
    }

    function cancel() {
      clearTimeout(timer);
      reject(signal.reason ?? new DOMException("Polling stopped", "AbortError"));
    }

    signal.addEventListener("abort", cancel, { once: true });
  });
}

async function fetchDownloadStatus(
  token: string,
  audienceId: number,
  projectId: string,
  signal: AbortSignal,
) {
  if (signal.aborted) {
    throw signal.reason ?? new DOMException("Polling stopped", "AbortError");
  }

  const requestController = new AbortController();
  let requestTimedOut = false;
  const cancelRequest = () => requestController.abort(signal.reason);
  const timeout = setTimeout(() => {
    requestTimedOut = true;
    requestController.abort();
  }, 15_000);

  signal.addEventListener("abort", cancelRequest, { once: true });

  try {
    const resp = await fetch(
      `https://api.delivr.ai/api/v1/audiences/${audienceId}/download?project_id=${projectId}`,
      {
        headers: { Authorization: `Bearer ${token}` },
        signal: requestController.signal,
      },
    );

    if (!resp.ok) {
      if (RETRYABLE_HTTP_STATUSES.has(resp.status)) {
        throw new RetryableRequestError(`Temporary HTTP ${resp.status}`);
      }
      throw new Error(`Download request failed with HTTP ${resp.status}`);
    }

    return (await resp.json()) as DownloadPreparation;
  } catch (error) {
    if (signal.aborted) {
      throw signal.reason ?? new DOMException("Polling stopped", "AbortError");
    }
    if (requestTimedOut) {
      throw new RetryableRequestError("Download status request timed out");
    }
    throw error;
  } finally {
    clearTimeout(timeout);
    signal.removeEventListener("abort", cancelRequest);
  }
}

async function pollFileStatus(
  token: string,
  audienceId: number,
  projectId: string,
  signal: AbortSignal,
  onProgress: (status: DownloadPreparation) => void,
) {
  let transientFailures = 0;

  while (!signal.aborted) {
    let data: DownloadPreparation;

    try {
      data = await fetchDownloadStatus(token, audienceId, projectId, signal);
      transientFailures = 0;
    } catch (error) {
      const retryable =
        error instanceof RetryableRequestError || error instanceof TypeError;
      if (!retryable) throw error;

      transientFailures += 1;
      const backoffMs = Math.min(1_000 * 2 ** (transientFailures - 1), 10_000);
      console.warn(`Download status unavailable; retrying in ${backoffMs}ms`, error);
      await wait(backoffMs, signal);
      continue;
    }

    onProgress(data);

    if (data.status === "ready") return data.output_links;
    if (data.status === "failed") throw new Error("File preparation failed");

    await wait(3_000, signal);
  }

  throw new DOMException("Polling stopped", "AbortError");
}

Use GET /api/v1/audiences/{id}/status instead when you only need a
lightweight, side-effect-free readiness check. It does not include detailed
stage or job progress.

Download

When the download endpoint returns ready, use the output_links from that
same response. The links are signed Parquet URLs valid for up to 7 days. If a
link expires, call the download endpoint again to obtain fresh URLs.

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 25 redacted rows: firmographics, role, intent fields, first_name, the sha256_lc_hem match key, and has_* flags indicating which contact fields exist. Raw contact data (emails, phones, LinkedIn) is only in the downloaded files. Use the sample 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://api.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 api.delivr.ai. The Taxonomy API is on api.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 with type: "intents" require at least one INTENT rule, regardless of segmentation_type. The type: "contact" and type: "list" audience types have different requirements. elixir is the former name for contact and is still accepted on create.
  • 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


Did this page help you?