409 Conflict when trying to disconnect participant via /api/v2/conversations/voice/participants

Just noticed that the disconnect endpoint is throwing a 409 Conflict instead of a 204 No Content, and I can’t figure out why the state check is failing.

We’ve built a ServiceNow script that listens for a custom webhook when an agent clicks “Remove Caller” in a multi-party voice conversation. The logic seems sound. I grab the active OAuth token from the credential store, construct the POST payload with the participant ID and the disconnect action, and fire it off to /api/v2/conversations/voice/participants.

The JSON body looks like this:

{
 "participantId": "3a4b5c6d-1234-5678-9012-34567890abcd",
 "action": "disconnect"
}

If the token is valid and the scope conversation:participant:write is present, this should work. I’ve verified the scope in the token decoder. The participant ID is definitely correct because I can see them in the GET response for that conversation. The error response is generic: {"code": "conflict", "message": "The requested action cannot be completed because of the current state of the resource."}.

I’ve tried waiting a second between the GET and the POST. I’ve tried using the conference:control scope instead. Nothing changes the 409. It’s almost like the API thinks the participant is already disconnected or in a transition state, but the UI still shows them as “Active”.

Is there a specific sequence I’m missing? Or does the disconnect action require a different HTTP method? I’m running the code in EST so the server logs should match up if I check the audit trail, but I’d rather fix the code than dig through logs all night. Has anyone hit this wall with multi-party calls? The docs don’t mention any pre-requisites other than the write scope. Feels like a race condition on the participant state.

This happens because the participant state not being strictly “connected” when the request hits the server. the api rejects disconnects on participants already in “queued” or “disconnected” states to prevent race conditions.

here’s how i handle it in my node middleware:

  • fetch the current conversation state first using GET /api/v2/conversations/voice/{conversationId}/participants.
  • check the state field for the target participant.
  • only proceed if state === 'connected'.
  • if it’s already disconnected, skip the api call entirely.
const participants = await platformClient.conversationsApi.getConversationVoiceParticipants(convId);
const target = participants.entities.find(p => p.id === participantId);

if (target.state === 'connected') {
 await platformClient.conversationsApi.postConversationVoiceParticipants(
 convId, 
 { action: 'disconnect', participantId: participantId }
 );
}

don’t assume the webhook trigger happens instantly. there’s often a few hundred ms delay where the state lags. adding that check saves you from 409s and keeps your logs clean.