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://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:
| 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". See Understanding Segmentation Types. |
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://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.
| Status | UI State | Description |
|---|---|---|
ready | Download button enabled | Files exist with valid URLs |
unloading | Stage progress | Files are being generated; show stage and optional progress |
converting | Stage progress | A requested CSV is being generated |
| Any other value | Preparing | Keep polling; legacy in-progress values include done and completed |
failed | Error state | File 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 onapi.delivr.ai. The Taxonomy API is onapi.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 with
type: "intents"require at least one INTENT rule, regardless ofsegmentation_type. Thetype: "contact"andtype: "list"audience types have different requirements.elixiris the former name forcontactand is still accepted on create. 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
- Understanding Segmentation Types: When to use Persona vs Account vs Audience
- 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 1 day ago
