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
- JWT token and organization ID (Authentication)
- A project ID (Account Setup)
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:
| Category | Label | Description |
|---|---|---|
contact | Contact | Job title, seniority, department, email status |
company | Company | Industry, employee count, revenue, location |
demographics | Demographics | Age, gender, income, homeowner |
intent | Intent | Score, 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_type | Control | Data Source |
|---|---|---|
multiselect | Multi-select dropdown (chips) | options[] from API |
select | Single-select dropdown | options[] from API |
text | Free-text input | User types a value |
number | Numeric input | User 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:
| Field | Option Count | Recommended Control |
|---|---|---|
company_industry | 346 | Searchable dropdown (client-side filter) |
job_title_normalized | 16,077 | Async search / typeahead (do not load into a static dropdown) |
| Most other multiselect fields | 2-26 | Standard multi-select dropdown |
4. Show Valid Operators
Each field declares its valid operators array. Map these to user-friendly labels:
| API Operator | Display Label | Used 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:
| Property | Type | Description |
|---|---|---|
fieldName | string | The field_key from the catalog, or a special field (INTENT, PERSONA, ACCOUNT, score) |
conditionRules.operator | string | One of the operators from the field's operators array |
conditionRules.value | string or string[] | For in/notin: array. For is/contains/etc.: string. For notnull/isnull: omit or "". |
Value rules:
- Always use
options[].value(lowercase), neverlabel - For
in/notin,valuemust be an array:["cxo", "vp"] - For
is/contains/startsWith/endsWith,valueis a single string:"software" - For numeric operators,
valueis 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:
| Field | Type | Description |
|---|---|---|
organization_id | string | Master organization ID |
project_id | string | Project (workspace) ID |
audience_name | string | Display name for this audience |
type | string | "intents" for intent-based audiences |
segmentation_type | string | "Audience", "Persona", or "Account" |
filter | object | The 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.
| Status | UI State | Description |
|---|---|---|
Pending | Spinner / "Queued" | Audience is queued for processing |
Syncing | Progress bar / "Processing" | Audience is being built |
Completed | Success / show size | Audience is ready -- display size (contact count) |
Failed | Error state | Show 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).
| Status | UI State | Description |
|---|---|---|
ready | Download button enabled | Files exist with valid URLs |
unloading | Spinner / "Preparing files" | Files are being generated |
no_task | Trigger download | Audience has never been exported -- call the download endpoint to start |
failed | Error state | File 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 onapiv3.delivr.ai. The Taxonomy API is onapiv2.delivr.ai. - Always use
options[].value(lowercase) in filter rules, notoptions[].label. The backend matches on lowercase values from the parquet data. - For
in/notinoperators,valuemust be an array (["cxo"]), not a string ("cxo"). - Audiences of type
intentsrequire at least one INTENT rule. Persona and Account types do not. job_title_normalizedhas 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/schemaendpoint (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
- Field Catalog API -- Complete API reference for field metadata
- Building Audience Filters -- Field-by-field reference for all filter options and operators
- Audience End-to-End -- Complete poll/preview/download flow with Python examples
- Intent Audiences API -- Full API reference
- Taxonomy API -- Browse 19,500+ topics for INTENT rules
Updated 4 days ago