There are no webhooks and no WebSockets — long-running jobs are polled. The most reliable pattern is to poll the resource’s own task endpoint when one exists, falling back to the generic /tasks/{id} otherwise.

Campaign status changes — resource-specific task endpoint

The Wave per-event pause/resume endpoint kicks off a background task and returns 204 No Content. Poll the per-event task endpoint for the result:
PUT  /events/{eid}/client_campaign_status?client_campaign_status=PAUSED
→ 204 No Content
GET  /events/{eid}/client_campaign_status/task
→ { "state": "SUCCESS" | "FAILURE" | "PENDING" | "STARTED", ... }
Don’t continuously poll in a tight loop — issue a single GET after the PUT, then wait for a user gesture or use a coarse interval (e.g. 3s).

Helper

type TaskState = "PENDING" | "STARTED" | "SUCCESS" | "FAILURE";

export async function awaitCampaignStatusTask(eid: string, {
  intervalMs = 3000,
  timeoutMs  = 5 * 60 * 1000,
  signal,
}: { intervalMs?: number; timeoutMs?: number; signal?: AbortSignal } = {}) {
  const startedAt = Date.now();
  while (!signal?.aborted) {
    if (Date.now() - startedAt > timeoutMs)
      throw new Error("Campaign-status task timed out");
    const t = await fd.get<{ state: TaskState }>(
      `/events/${eid}/client_campaign_status/task`,
    );
    if (t.state === "SUCCESS") return;
    if (t.state === "FAILURE") throw new Error("Campaign-status task failed");
    await sleep(intervalMs);
  }
}

Generic /tasks/{id} polling

Some services issue a task id and expose GET /tasks/{id} for status. Status values follow the FastAPI/Celery convention: PENDING, STARTED, SUCCESS, FAILURE. The result is delivered when state === "SUCCESS".
type TaskState = "PENDING" | "STARTED" | "SUCCESS" | "FAILURE";

export async function awaitTask(
  taskId: string,
  { intervalMs = 3000, timeoutMs = 5 * 60 * 1000, signal }: {
    intervalMs?: number; timeoutMs?: number; signal?: AbortSignal;
  } = {},
): Promise<unknown> {
  const startedAt = Date.now();
  while (!signal?.aborted) {
    if (Date.now() - startedAt > timeoutMs)
      throw new Error(`Task ${taskId} timed out`);
    const t = await fd.get<{ state: TaskState; result?: unknown }>(`/tasks/${taskId}`);
    if (t.state === "SUCCESS") return t.result;
    if (t.state === "FAILURE") throw new Error(`Task ${taskId} failed`);
    await sleep(intervalMs);
  }
  throw new Error("aborted");
}
If the resource exposes its own task endpoint, prefer that over the generic /tasks/{id} — it scopes correctly per-event/per-resource.

Don’t poll harder than necessary

Server budgets assume gentle polling:
ResourceReasonable interval
Notification stats badge~10 s
Messages unread badgelow-frequency (one-shot after ~15 min, plus refresh on user actions like login or returning to a list)
Task state polling~3 s
Going significantly faster (e.g. polling task state every 200 ms) 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: ["campaign-status-task", eid],
  queryFn:  () => fd.get(`/events/${eid}/client_campaign_status/task`),
  refetchInterval: (data) => {
    if (data?.state === "SUCCESS" || data?.state === "FAILURE") return false;
    return 3000;
  },
});
React Query handles unmount cancellation, 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). Several reference-webapp polls don’t — don’t copy that.
Not completed/failed/running. Match against the uppercase enum values returned by the backend.
The reference webapp fires a single GET .../client_campaign_status/task after the PUT, not a continuous loop. Mirror that to stay under rate budgets.