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

State model — conceptual, not enums

Treat DRAFT / VALIDATED / PUBLISHED as conceptual labels, not explicit server/FE enum values. The reference FE derives state from observable facts:
no setup
  │  POST /setup_processes/{eid}                  ← start wizard

DRAFT          (setup_process exists; GET returns it — 404 means "no draft")
  │  PUT /setup_processes/{eid}                   ← autosave on each edit (~1s debounce)
  │  DELETE /setup_processes/{eid}                ← cancel

VALIDATED      (client-side gate before boost — not a server state)
  │  POST /setup_processes/{eid}/boost            ← publish

PUBLISHED      (derived from GET /events/{eid}/campaigns — any campaign present)
  │  POST /campaigns_setup                        ← edit one published cluster

COMPLETED      (event runtime ends)

Payload structure — arrays, not TC-keyed objects

The setup payload uses arrays for creatives / targeting / integration details, each entry carrying its tc and tc_run_id. It does not use taste-cluster-keyed objects.
{
  "goal":         "UTILIZATION",
  "total_budget": 5000,
  "start_date":   "2026-05-25",
  "end_date":     "2026-06-12",

  "creatives": [
    { "tc": "family_outings_munich", "tc_run_id": 42, "headline": "...", "body": "...", "media_url": "...", "cta": "GET_TICKETS" }
  ],
  "targeting": [
    { "tc": "family_outings_munich", "tc_run_id": 42, "geo": [], "demographics": {}, "saved_audience_id": null }
  ],
  "integration_details": [
    { "tc": "family_outings_munich", "tc_run_id": 42, "ad_account_id": "act_123", "page_id": "page_456", "pixel_id": "pix_789" }
  ],

  "conversion_id":     null,
  "lead_form_id":      null,
  "custom_event_type": null,
  "dsa_payor":         "Acme Concerts GmbH",
  "dsa_beneficiary":   "Acme Concerts GmbH"
}
goal is enum-style: UTILIZATION, ROAS, VISIBILITY, LINK_CLICKS — not tickets / revenue / reach.

Step 1 — wizard prefetch

Endpoints used by the reference wizard:
EndpointPurpose
GET /events/{eid}/default_campaigns_parametersSeed budget / goal / clusters / integration defaults.
GET /events/{eid}/budget_min_max?runtime_in_days=Clamp the budget slider ({ upper_limit, lower_limit }).
GET /events/{eid}/suggested_tcs?budget=&goal=Ranked cluster suggestions ([{ tc, tc_run_id }]).
GET /setup_processes/call_to_action/?goal=&has_pixel=&lead_form_id=Allowed CTAs.
GET /setup_processes/event_types?...Event-type metadata (ad-platform step).
Not used by the current reference wizard flow — don’t present these as part of the lifecycle:
  • GET /events/{eid}/clusters
  • GET /events/{eid}/campaigns_expected_value
  • PUT /events/{eid}/init_campaigns_setup

Step 2 — create the draft

POST /setup_processes/{eid}
{ "goal": "UTILIZATION", "total_budget": 5000, "start_date": "...", "end_date": "...",
  "creatives": [], "targeting": [], "integration_details": [] }
Returns the persisted setup_process. From here every edit is a PUT.

Step 3 — autosave (debounced + abort)

Debounce edits ~1s, then PUT the full payload. The reference FE uses a module-level AbortController so only the most recent PUT survives:
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 });
}
Cross-tab edits race — there’s no server-side merge.

Step 4 — publish (boost)

The reference publish flow, in order:
  1. Re-fetch GET /setup_processes/{eid} to confirm server state.
  2. Abort any in-flight autosave PUT.
  3. Enrich accounts via enrichAccountsAsync (not enrichAssets).
  4. Run client-side validation over integrations + creatives + targeting. On failure: log to Sentry tagged INVALID_SETUP and do not call boost.
  5. POST /setup_processes/{eid}/boostno request body; FE expects 200.
export async function publishSetup(eid: string) {
  setupController?.abort();
  const fresh = await fd.get(`/setup_processes/${eid}`);
  await enrichAccounts(fresh);
  assertValid(fresh);                       // throws → no boost
  return fd.post(`/setup_processes/${eid}/boost`);   // no body; expect 200
}

Step 5 — edit a published cluster

POST /campaigns_setup
{ "eid": "evt_abc123", "tc": "family_outings_munich", "tc_run_id": 42,
  "audience_id": "aud_777", "creatives": [], "targeting": [] }
POST /campaigns_setup (plural, no {eid} in URL, eid in body) is the published-cluster edit endpoint. It is not PUT /setup_processes/{eid} (the draft endpoint). Different lifecycle stages.

Cancel

DELETE /setup_processes/{eid}
The reference FE sends the delete without an explicit ?cancel=true, expects 200 (not 204), and additionally calls deleteUserReports({ setup_process_ids: [...] }).

Reset

PUT /events/{eid}/reset_campaigns_setup exists in the API layer, but the current reference webapp UI does not invoke it. Document it as available-but-unused rather than part of the core flow.

Media uploads

Wizard creative media is uploaded through storage multipart (api/storage), not POST /media. See File uploads. The standalone POST /media endpoint exists but the reference webapp’s campaign creatives don’t use it.

Optional / context-specific endpoints

These belong to other contexts — not core lifecycle calls:
EndpointWhere it’s actually used
GET /campaigns/meta_tags?url=Social-share modal
GET/POST/PUT /prompts/custom_promptSettings

Gotchas

creatives[], targeting[], integration_details[], each entry carrying tc + tc_run_id.
UTILIZATION / ROAS / VISIBILITY / LINK_CLICKS.
DRAFT/VALIDATED/PUBLISHED aren’t server enums. Published is derived from GET /events/{eid}/campaigns.
POST /setup_processes/{eid}/boost with no payload; success is 200.
Plain DELETE /setup_processes/{eid}; also clears user reports.
Published-cluster edits vs draft edits — different endpoints.
Wizard uploads go through api/storage presigned multipart.
Not part of the current wizard flow.