There are no webhooks and no WebSockets — long-running jobs are polled. Two patterns cover everything:
  1. Generic task polling — for status-change tasks that return a task id.
  2. Resource-status polling — for resources that carry a status field (packages, taste-cluster runs).

Pattern 1 — Generic task polling

Some endpoints kick off a background task and return a task id:
PUT /events/{eid}/client_campaign_status?client_campaign_status=PAUSED
→ 202 Accepted
{ "task_id": "task_abc" }
Poll for completion via the generic tasks endpoint:
GET /tasks/{task_id}
→ { "id": "task_abc", "state": "running" | "completed" | "failed", "result": ..., "error": ... }

Helper

type TaskState = "queued" | "running" | "completed" | "failed";

export async function awaitTask(
  taskId: string,
  { intervalMs = 2000, timeoutMs = 5 * 60 * 1000 } = {},
): Promise<unknown> {
  const startedAt = Date.now();
  let backoff = intervalMs;

  while (true) {
    if (Date.now() - startedAt > timeoutMs)
      throw new Error(`Task ${taskId} timed out`);

    const t = await fd.get<{ state: TaskState; result?: unknown; error?: string }>(
      `/tasks/${taskId}`,
    );

    if (t.state === "completed") return t.result;
    if (t.state === "failed")    throw new Error(t.error ?? `Task ${taskId} failed`);

    await sleep(backoff);
    backoff = Math.min(backoff * 1.5, 10_000);   // gentle escalation, cap at 10s
  }
}
Used for:
  • PUT /events/{eid}/client_campaign_status?... — has a task-state poll at GET /events/{eid}/client_campaign_status/task.
If the resource exposes its own task endpoint, prefer that over the generic /tasks/{id}.

Pattern 2 — Resource-status polling

Some resources carry their own status field that transitions over time. Poll the resource itself:
async function awaitReady<T extends { status: string }>(
  path: string,
  terminal: (s: string) => boolean,
  { intervalMs = 5000, timeoutMs = 10 * 60 * 1000, signal }: {
    intervalMs?: number; timeoutMs?: number; signal?: AbortSignal;
  } = {},
): Promise<T> {
  const startedAt = Date.now();
  while (!signal?.aborted) {
    if (Date.now() - startedAt > timeoutMs)
      throw new Error(`${path} timed out`);
    const r = await fd.get<T>(path);
    if (terminal(r.status)) return r;
    await sleep(intervalMs);
  }
  throw new Error("aborted");
}
Used for:
  • Package BuilderGET /package_builder/packages/{id} polled every 5s until status === "DONE" | "FAILED" | "EXPIRED".
  • Taste cluster runs / ITC — typically caller-driven (kick the DAG, poll the per-event endpoint).

Pattern in the Package Builder

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

  useEffect(() => {
    const controller = new AbortController();
    awaitReady<Package>(
      `/package_builder/packages/${packageId}`,
      (s) => s === "DONE" || s === "FAILED" || s === "EXPIRED",
      { intervalMs: 5000, signal: controller.signal },
    )
      .then(setPkg)
      .catch(e => {
        if (e.message !== "aborted") surfaceError(e);
      });
    return () => controller.abort();
  }, [packageId]);

  if (!pkg) return <PollingSkeleton />;
  return <PackageContent pkg={pkg} />;
}
Always pass an AbortSignal tied to component lifetime. The reference webapp doesn’t do this and leaks polling loops on unmount.

Don’t poll harder than necessary

Server budgets assume gentle polling:
ResourceReference webapp interval
Package status5s
Notification stats badge10s
Messages unread badge15 min
Task state2s (with backoff)
Going significantly faster (e.g. polling task state every 200ms) will trigger 429s and may surface as account-level rate throttling later.

Combining with React Query

If you’re using @tanstack/react-query, leverage refetchInterval:
const query = useQuery({
  queryKey: ["package", packageId],
  queryFn:  () => fd.get<Package>(`/package_builder/packages/${packageId}`),
  refetchInterval: (data) => {
    if (data?.status === "DONE" || data?.status === "FAILED" || data?.status === "EXPIRED")
      return false;
    return 5000;
  },
});
This handles unmount cancellation for you, deduplicates concurrent subscribers, and stops automatically on terminal states.

Caveats

There is no callback URL you can register. Plan for polling on your side. (If your integration needs near-real-time push, talk to your account contact — bespoke webhook arrangements are possible.)
Always tie your poll to an AbortController (or component-mounted flag, or React Query). The reference webapp’s package poll doesn’t do this — don’t copy that bug.
A task that’s been running for 30 minutes is probably stuck; bombarding its endpoint at 200ms doesn’t help. Use linear or exponential backoff capped at 10s.
Some task endpoints return state: "completed" with an empty result. That’s still success — the side effect happened on the server.