The Wave wizard is built on the setup_processes/{eid} resource — one row per event that holds the draft (and, after publish, the running configuration). Get this state machine right and you have Wave.

The state machine

no setup

  │  POST /setup_processes/{eid}                  ← createAsync (start wizard)

DRAFT (server-stored setup_process)
  │  PUT /setup_processes/{eid}                   ← autosave on every edit (1s debounce)
  │  DELETE /setup_processes/{eid}?cancel=true    ← cancel

VALIDATED (client-side gate: integrations + creatives + targeting valid)

  │  POST /setup_processes/{eid}/boost            ← publishAsync

PUBLISHED  ─────────────────────────────────▶  RUNNING (visible in /events/{eid}/campaigns)
  │  POST /campaigns_setup                        ← editPublishedSetup (per-cluster patch)
  │  POST /campaigns/new_runtime_budget?...       ← change budget / end date
  │  PUT  /events/{eid}/reset_campaigns_setup     ← nuke & restart

COMPLETED (event runtime ends)

The setup_process payload

{
  "goal":              "tickets",
  "total_budget":      5000,
  "start_date":        "2026-05-25",
  "end_date":          "2026-06-12",
  "creatives": {
    "family_outings_munich": {
      "headline":  "Family night at Olympiahalle",
      "body":      "Bring everyone — kids free under 6.",
      "media_url": "https://media.future-demand.com/...",
      "cta":       "GET_TICKETS"
    }
  },
  "integration_details": {
    "ad_account_id": "act_123",
    "page_id":       "page_456",
    "pixel_id":      "pix_789"
  },
  "conversion_id":   null,
  "lead_form_id":    null,
  "custom_event_type": null,
  "targeting": {
    "family_outings_munich": {
      "geo":          [{ "city": "München", "radius_km": 50 }],
      "demographics": { "age_min": 25, "age_max": 55 },
      "saved_audience_id": null
    }
  },
  "dsa_payor":       "Acme Concerts GmbH",
  "dsa_beneficiary": "Acme Concerts GmbH",
  "tc_run_id":       42
}

Step-by-step: the wizard

Step 1 — fetch defaults

Before showing the wizard, pull the defaults so the user starts from a sensible point:
GET /events/{eid}/default_campaigns_parameters
# → { total_budget, goal, tcs }

GET /events/{eid}/budget_min_max?runtime_in_days=14
# → { min, max }

GET /events/{eid}/clusters
# → { tc_run_id, clusters: [...] }

GET /events/{eid}/suggested_tcs?budget=5000&goal=tickets
# → ranked suggestions

GET /events/{eid}/campaigns_expected_value?goal=tickets&budget=5000
# → { expected_tickets, expected_revenue } per TC

GET /setup_processes/call_to_action/?goal=tickets&has_pixel=true&lead_form_id=
# → ["GET_TICKETS", "LEARN_MORE", ...]

GET /setup_processes/event_types?campaign_goal=tickets&lead_form_id=
# → ["concert", ...]

Step 2 — create the draft

POST /setup_processes/{eid}
{
  "goal": "tickets",
  "total_budget": 5000,
  "start_date": "2026-05-25",
  "end_date":   "2026-06-12",
  "tc_run_id":  42,
  // ... full initial payload
}
Returns the persisted setup_process. From now on, every edit is a PUT.

Step 3 — autosave (debounced)

Debounce user edits by ~1 second, then PUT the full payload:
const debouncedSync = debounce(async (payload: SetupProcess) => {
  try {
    await syncSetup(eid, payload);
  } catch (e) {
    if (e.name === "AbortError") return;
    surfaceError(e);
  }
}, 1000);

onEdit(payload => debouncedSync(payload));
The reference webapp uses a module-level AbortController:
let setupController: AbortController | null = null;

export async function syncSetup(eid: string, payload: SetupProcess) {
  setupController?.abort();
  setupController = new AbortController();

  return fd.put(`/setup_processes/${eid}`, payload, {
    signal: setupController.signal,
  });
}
This ensures only the most recent PUT survives. Cross-tab edits will race — there’s no server-side merge.

Step 4 — publish

Before /boost, the reference webapp:
  1. Re-fetches GET /setup_processes/{eid} to confirm server state.
  2. Aborts any in-flight PUT.
  3. Calls metaAssetsApi.enrichAssets(...) for last-mile validation.
  4. Runs a client-side validation pass (creatives × clusters, targeting complete, integrations connected).
Only if all pass does it call:
POST /setup_processes/{eid}/boost
If client-side validation fails, the reference webapp sends a Sentry message tagged INVALID_SETUP and refuses to call /boost. Surface the failures to the user instead.

Step 5 — published

After publish, the setup_process is frozen — no more PUTs. The running campaigns are now visible in GET /events/{eid}/campaigns. To edit one cluster’s creative or targeting after publish:
POST /campaigns_setup
{
  "eid":                "evt_abc123",
  "tc":                 "family_outings_munich",
  "tc_run_id":          42,
  "audience_id":        "aud_777",
  "saved_audience_id":  null,
  "creatives": { ... },
  "targeting": { ... }
}
The endpoint is POST /campaigns_setup (plural, no {eid} in the URL). The eid goes in the body. It is not PUT /setup_processes/{eid}. This is the single most common source of “why is my edit not saving” bugs.

Step 6 — change runtime / budget on a running campaign

# Preview
GET /campaigns/new_runtime_budget?eid={eid}&budget=&end_date=&start_date=
# → { daily_budget, projected_spend, ... }

# Apply
POST /campaigns/new_runtime_budget?eid={eid}&budget=&end_date=&start_date=
The reference webapp also surfaces:
GET /campaigns/current_runtime_budget/{eid}
# → { budget, end_date, start_date, daily_budget }

Step 7 — nuke and restart

PUT /events/{eid}/reset_campaigns_setup
Returns 204. Wipes the setup_process and the campaigns; the user starts over.

Other endpoints used in the wizard

PurposeEndpoint
Fetch URL meta tags (creative editor)GET /campaigns/meta_tags?url=...
Upload creative mediaPOST /media (multipart) — note: API host without /v3 prefix
Get/save custom Affinity promptGET/POST/PUT /prompts/custom_prompt
Translate NL → IDs (for custom audiences)GET /nlq/ids_from_nl_description?description=...
Reset draft mid-wizardDELETE /setup_processes/{eid}?cancel=true
User-reports cleanup on deleteuserReportsApi.deleteUserReports({ setup_process_ids: [id] })

Reference implementation — the setup context

// CampaignSetupContext.ts (simplified)
export function useCampaignSetup(eid: string) {
  const [state, setState] = useState<SetupProcess | null>(null);

  // Load existing draft (or null if 404)
  useEffect(() => {
    fd.get<SetupProcess>(`/setup_processes/${eid}`)
      .then(setState)
      .catch(e => e.status === 404 ? setState(null) : Promise.reject(e));
  }, [eid]);

  const startDraft = useCallback(async (initial: SetupProcess) => {
    const created = await fd.post<SetupProcess>(`/setup_processes/${eid}`, initial);
    setState(created);
  }, [eid]);

  const editDraft = useDebouncedCallback(async (next: SetupProcess) => {
    setState(next);
    setupController?.abort();
    setupController = new AbortController();
    await fd.put<SetupProcess>(`/setup_processes/${eid}`, next, {
      signal: setupController.signal,
    });
  }, 1000);

  const publish = useCallback(async () => {
    setupController?.abort();
    const fresh = await fd.get<SetupProcess>(`/setup_processes/${eid}`);
    assertValid(fresh);                  // throw if invalid
    return fd.post(`/setup_processes/${eid}/boost`);
  }, [eid]);

  const cancel = useCallback(async () => {
    setupController?.abort();
    return fd.delete(`/setup_processes/${eid}?cancel=true`);
  }, [eid]);

  return { state, startDraft, editDraft, publish, cancel };
}

Gotchas

The URL is /setup_processes/{eid}, not /setup_processes/{setupId}. Concurrent edits across tabs race. The reference webapp aborts older PUTs via a module-level AbortController — copy that, or you’ll have lost-edit bugs.
Drilling for the third time: published-cluster edits go to POST /campaigns_setup (plural, no eid in URL, eid in body). Bookmark this.
The endpoint expects the full setup_process, not a JSON patch. If you send a partial, fields you didn’t include get reset to default.
/boost errors are surfaced as a generic 400 in production. Catching invalid setup before the call keeps users out of “wait, why did publish fail” loops.
The reference webapp injects the user’s UI locale into creative defaults. Users with mismatched locale vs event country see surprising suggested copy. Decide whether to mirror this or use the event’s locale.
POST /setup_processes/{eid}/boost either returns 2xx (success, see campaigns in /events/{eid}/campaigns) or 4xx (validation/integration failure). No async task to poll.