The Package Builder produces standalone audience packages — Affinity- derived customer lists tied to one event or a set of events. Used to push segments into CRMs, send CSVs to media buyers, or generate email targets. This guide covers all three Package Builder surfaces:
  1. List (/package-builder) — existing packages, optional merge.
  2. Create wizard (/package-builder/create) — multi-step form.
  3. Results (/package-builder/results/:packageId) — status polling, evaluation, exports.

Prerequisites

  • An authenticated user with Permissions.backhaul.
  • X-Preferred-Partner-Id set.

1. List

Call chain

On mount:
#MethodPathPurpose
1GET/package_builder/packages/All packages for the partner.

Response

[
  {
    "id":         "pkg_555",
    "title":      "Munich shows Q3",
    "status":     "DONE",
    "merge_id":   null,
    "created_at": "2026-05-15T10:30:00Z",
    "events":     [{ "id": "evt_abc", "title": "..." }, ...],
    "targeting_mode": "lookalike"
  }
]
Status values: INITIALIZING, INPROGRESS, DONE, FAILED, EXPIRED.

Actions

ActionEndpoint
Open a packagenavigate to /package-builder/results/:id
Merge selectedPOST /package_builder/packages/merge?packages_ids=A&packages_ids=B (repeated query params, no body)
Createnavigate to /package-builder/create

2. Create wizard

The wizard has three steps: Select Events → Targeting + Customer Segmentation → Preview.

Call chain

On mount: nothing — wizard state is hydrated from localStorage["packageBuilderState"]. During the wizard:
StepMethodPathPurpose
Event searchGET/events?search=&page=&since=Autocomplete picker.
(optional) Customer NL promptGET/nlq/ids_from_nl_description?description=&result_type=&language=Resolve a natural-language customer filter into IDs.
On submit:
MethodPathPurpose
POST/package_builder/packagesCreate the package (async).

Create payload

{
  "num_packages":   3,
  "n_top_events":   3,
  "event_ids":      [12345, 23456, 34567],
  "package_title":  "Munich shows Q3",
  "targeting_mode": "lookalike",
  "filters": {
    "included_customers":            [],
    "excluded_customers":            [],
    "exclude_buying_behavior_above": null,
    "exclude_buying_behavior_below": null,
    "exclude_old_purchases_before":  "2024-01-01",
    "exclude_recent_purchases_after": null,
    "exclude_ticket_categories":     ["VIP"]
  }
}
The reference webapp hard-codes num_packages: 3, n_top_events: 3. Don’t copy this if your UX gives the user real control. Response:
{ "id": "pkg_555" }
Then redirect to /package-builder/results/pkg_555. The package is in INITIALIZING state on creation.

3. Results

Call chain

On mount with :packageId:
#MethodPathPurpose
1GET/package_builder/packages/{id}Package payload (status, content, events, …).
2GET/package_builder/packages/{id}/backhaul_evaluation_detailsCurrent attribution settings.
3GET/package_builder/packages/{id}/backhaul_evaluationEvaluation results.

Status polling

If the package’s status is INITIALIZING or INPROGRESS, poll:
async function pollUntilReady(packageId: string) {
  while (true) {
    const pkg = await fd.get<Package>(`/package_builder/packages/${packageId}`);
    if (pkg.status === "DONE")     return pkg;
    if (pkg.status === "FAILED")   throw new Error("Package generation failed");
    if (pkg.status === "EXPIRED")  return pkg;   // user can regenerate
    await sleep(5000);
  }
}
The reference webapp polls every 5 seconds via a setTimeout chain.
The reference webapp’s poll has no unmount cancellation — if the user navigates away mid-poll, the next tick still runs against a dead component. Gate the loop on a mounted flag (or AbortController).

Package payload

{
  "id":         "pkg_555",
  "title":      "Munich shows Q3",
  "status":    "DONE",
  "merge_id":   null,
  "created_at": "2026-05-15T10:30:00Z",
  "targeting_mode": "lookalike",
  "filter_settings": { ... },
  "events": [...],
  "content": {
    "family_outings_munich": {
      "customers": 184,
      "items":     412,
      "purchase":  18540,
      "preview":   [{ "id": "cust_1", ... }]
    }
  }
}

Evaluation (Backhaul attribution)

# Read current settings
GET /package_builder/packages/{id}/backhaul_evaluation_details
# → { backhaul_evaluation_attribution_window, backhaul_evaluation_model, email_sent_date }

# Save (triggers re-eval)
PUT /package_builder/packages/{id}/backhaul_evaluation_details
{
  "backhaul_evaluation_attribution_window": 14,
  "backhaul_evaluation_model": "EVENTS_FROM_PACKAGE",
  "email_sent_date": "2026-06-01"
}

# Read results
GET /package_builder/packages/{id}/backhaul_evaluation
# → { clusters: { <tc>: { customers, items, purchase } } }
After PUT, re-fetch the GET — the reference webapp uses an evaluationReloadKey counter to invalidate. See Attribution for the model semantics.

Per-cluster actions

ActionEndpoint
Download CSVGET /package_builder/packages/{id}/{tc}/customer_list — blob response, trigger a.download client-side.
Export to CRMRouted via your data-integrations layer. Payload: { crm_name, secret_id, container_name, packageId, tc }.
Regenerate (EXPIRED only)POST /package_builder/packages/{id}/regenerate — re-enters INITIALIZING.

Reference implementation — CSV download

export async function downloadCustomerListCsv(packageId: string, tc: string) {
  const blob = await fd.get<Blob>(
    `/package_builder/packages/${packageId}/${encodeURIComponent(tc)}/customer_list`,
    { responseType: "blob" },
  );
  const url = URL.createObjectURL(blob);
  const a   = document.createElement("a");
  a.href = url;
  a.download = `${tc}-customers.csv`;
  a.click();
  URL.revokeObjectURL(url);
}
tc is URL-encoded; do not slugify it for this endpoint.

Reference implementation — Results page

export function PackageResultsPage({ packageId }: { packageId: string }) {
  const [pkg, setPkg] = useState<Package | null>(null);
  const mountedRef = useRef(true);

  useEffect(() => {
    mountedRef.current = true;
    return () => { mountedRef.current = false; };
  }, []);

  useEffect(() => {
    let cancelled = false;

    async function loop() {
      while (!cancelled && mountedRef.current) {
        const p = await fd.get<Package>(`/package_builder/packages/${packageId}`);
        if (cancelled) return;
        setPkg(p);
        if (p.status === "DONE" || p.status === "FAILED" || p.status === "EXPIRED") return;
        await sleep(5000);
      }
    }
    loop();
    return () => { cancelled = true; };
  }, [packageId]);

  if (!pkg) return <Skeleton />;
  if (pkg.status === "INITIALIZING" || pkg.status === "INPROGRESS")
    return <BuildingPackage progress={pkg.progress} />;
  if (pkg.status === "FAILED")
    return <PackageFailed />;
  if (pkg.status === "EXPIRED")
    return <PackageExpired onRegenerate={() => regenerate(packageId)} />;

  return <PackageContent pkg={pkg} />;
}

Gotchas

The reference webapp’s bug, not a feature. Gate your loop on a mounted flag — see the implementation above.
A merge_id of "123_456_789" means this package was merged from packages 123, 456, 789. Split client-side; no separate API call.
URL-encode but do not slugify. The campaign-side endpoints have a different convention — see IDs and Hierarchy.
The reference webapp increments a counter to re-run the evaluation fetches. If you use React Query or SWR, invalidate the query keys instead.
If your partner is on a demo account, the reference webapp skips the API entirely and renders src/mock/packageBuilderResultsV2.json. Replicate only if you need demo parity.
Legacy naming in the reference webapp. Doesn’t matter for your client — but if you read the source for reference, don’t be confused.