Troubleshooting: campaign and conference state-machine transition errors
A state-machine violation is a 409 that refused your write because the object you are updating has moved on. The guard did not lose the race — your read did. Read the current status with a GET first, and you will see the transition the code names, or the one it replaced. All five codes on this page close on the same recovery step; never mutate outside the legal transition, never loop the retry. The five surfaces grouped here:Campaign states — the legal-transition map
A marketing campaign moves through exactly this progression (mirror of the campaign lifecycle concept page):approved is the terminal state for this gate; the campaign then follows
the wider draft → scheduled → sending → running → completed arc that the
concept page owns. The two codes below gate only the first three hops —
they are the door on a write that assumes the campaign still sits in the
state your UI last observed.
Per-transition legality table
Each row names the only transition the write is trying to perform, the only starting states that accept it, and the code you get when the precondition no longer holds:
The legality gate runs as an atomic flip: the write reads the current
status, decides it is in the allowed list, and flips — all in one
transaction. A 409 from this gate means the row’s status moved between
your earlier GET and this write, or your GET never happened and your UI
is carrying a stale state map.
CAMPAIGN_NOT_DRAFT — submit-for-approval gate
POST /campaigns/:id/approvals opens the supervisor queue on a campaign
your trust tier requires sign-off for. The only legal starting states are
draft and scheduled — everything else already holds a live
disposition the approval flow cannot reset.
- GET the campaign first. Read
statusback fromGET /campaigns/:idbefore you react. Whatever it says now is the legal truth. - Match on the status. If it moved to
pending_approvalsince you queued the submit, another writer beat you. If it moved tosendingorrunning, a supervisor approved it while your request was in flight. If it moved tocancelledorfailed, the approval path is closed. - Re-route. When the current state is one of the legal starts, retry the same POST once. When it is not, close the loop — the duplicate attempt is the bug to fix, not the gate.
What NOT to try
- Never PATCH the campaign back to
draftto force the gate open — an approved campaign insending/runningrejects the rewrite with its own gate, and an approval that already landed is not retractable via the status field. - Never loop the same POST — the 409 is deterministic. Each retry appends a fresh rejection to your audit trail without changing the campaign’s actual state.
CAMPAIGN_NOT_PENDING_APPROVAL — approve / reject race
POST /campaigns/:id/approvals/:approvalId/approve (or /reject) closes
the supervisor decision. Both flip the campaign out of
pending_approval; both refuse with the same code when the campaign has
already moved.
- The row already moved. Either a second supervisor decided first
(their
decided_by_user_idis on the approval row), or the integration you are holding took a path the gate does not accept (an operator edited the campaign and reverted it todraft). - List the pending queue again.
GET /campaigns/approvals/pending?campaign_id=:idreturns the current decision surface. If the row is gone, the decision already landed. - Rely on idempotence. If your goal was “approve this campaign” and the list shows it is no longer pending, treat the goal as reached — the 409 you just saw is the proof another supervisor closed it first.
What NOT to try
- Never re-approve against a second approval row for the same campaign — the first decision sticks and subsequent approvals are redundant.
- Never interpret the 409 as a transient fault; the precondition read is the whole point, and looping widens the supervisor audit log.
CONFERENCE_STATE_UNRESOLVED — the lock-a-room gate
POST /voice/conferences/:id/lock refuses when no leg in the room is
currently connected. A lock only applies to a live room: legs that are
still pending, dialing, or ringing are named as unresolved, legs in
any terminal state (failed, no_answer, busy, rejected,
disconnected) are already settled, and an empty room resolves to the
same refusal.
The gate reads the room’s participant roster — the same one the
GET /voice/conferences/:id response and the participant_joined /
participant_left webhooks present — and refuses whenever zero
participants are in connected.
- Poll the roster once.
GET /voice/conferences/:idreturns the participant list; read it before reacting. Theunresolvedcount inerror.detailstells you whether legs are still dialing or already settled. - Wait for one
participant_joined. Theconference.participant_joinedwebhook fires the moment a leg enters the room; the gate is open from that point on. - Then POST the lock once. The delegate only needs the first connected participant — the rest of the room can still be dialing.
What NOT to try
- Never retry the lock in a tight loop while legs are dialing — poll
GET /voice/conferences/:idon a backoff (the roster converges in milliseconds once a leg answers). - Never treat an
unresolvedcount of zero as a green light — that is the settled-but-never-connected case the gate closes explicitly.
INVALID_STATE_TRANSITION — the generic 409
Whenever a route guards its write with an atomic status flip — the same “read the precondition, flip, commit” shape every state-machine surface uses — the genericINVALID_STATE_TRANSITION code covers the
rest of the corpus. The code name is intentionally broader than the
per-surface codes above; read it as “the precondition a specific route
declared did not pass.”
- Identify the route. The
error.message(and the endpoint your request hit) names the transition it tried — match it back to the “From an error code to a runbook” table on the hub. - GET the current state first. Before any retry, re-read the object.
- Re-issue once, with the correct state. The route’s status-whitelist flip rejects stale preconditions deterministically; one retry after a fresh read is all that is ever safe.
INVALID_STATE_TRANSITION is the catch-all to route to when a take-over,
queue-flow flip, or lifecycle write you control fails with 409 and your
surface is not one of the four named above. The
Error Code Reference keeps the per-surface
message strings; this page owns the recovery shape.
INVARIANT_VIOLATION — the take-over sentinel
A supervisor action — listen, whisper, barge, unlisten — must never resolve to the retired provider branch. When the routing layer resolves a supervisor leg to a path outside the current provider contract, the take-over refuses with409 INVARIANT_VIOLATION and a route-level log
breadcrumb.
- Read the leg stamp.
GET /voice/calls/:idreturns the call metadata the resolver read; the refuse names the provider branch it resolved to indetails. - Re-stamp or terminate. A leg stamped with a pre-cutover provider value or an explicit override env needs a re-dial; terminate the supervisor leg and re-dial against the current softswitch routing contract.
- Escalate with the breadcrumb. When the refuse persists past a
re-dial, the platform-side config your tenant inherited is the cause —
open a ticket with the request id and the
verbyou attempted.
Decision checklist
Run these in order on any of the five codes:- Re-read the object (
GET /campaigns/:id,GET /voice/conferences/:id,GET /voice/calls/:id). - Match the current status against the legality table for the surface you called.
- Re-issue the same write at most once, after the read reflects the new precondition.
- If the precondition still fails, the object is in a disposition the write cannot advance — treat the current status as the terminal answer, and close the loop from your side.
When to escalate
- The same
INVALID_STATE_TRANSITIONrecurs on multiple surfaces — the gate contract your tenant relies on is ambiguous; open a ticket with the endpoint and theerror.detailsblob. CAMPAIGN_NOT_PENDING_APPROVALlands repeatedly because more than one supervisor in your tenant holds the queue — the visibility contract in the approvals list is what you lean on; ask support whether the pending-list shape should include a claim row.CONFERENCE_STATE_UNRESOLVEDlands when the webhook stream shows at least oneparticipant_joinedbefore your POST — the roster and the webhook stream disagree; file the conference id and the request id together.INVARIANT_VIOLATIONreturns averbyou did not configure — the legacy provider stamp is in metadata you inherited; the platform team needs the legacy record to look at, not just the request id.
Cross-references
- Campaign lifecycle concept owns the
campaign’s full
draft → … → completedarc; the approval gate this page decodes is the front-door subset. - ACD queue model owns the routing shape behind queue-flow state transitions; the conference and take-over flows this page gates are the voice-plane sibling of the queue model.
- Supervisor take-over failures
owns the
SUPERVISOR_*family this page’sINVARIANT_VIOLATIONsits beside. - Conference lifecycle failures
owns the event flow the
CONFERENCE_STATE_UNRESOLVEDgate reads. - Campaign and journey enrollment errors owns the runtime-refusal family (audience, enrollment) beside the launch-state family this page gates.