Sprinklr's Omnichannel Handover Protocol lets an external or third-party bot take part in a live customer conversation on a modern messaging channel, and then hand that conversation to Sprinklr for a human agent when appropriate.
Thread control is the mechanism that decides who is currently allowed to reply on a conversation thread: your bot, another bot, or Sprinklr itself. The Thread Control API drives that mechanism programmatically.
Before you begin. Your bot must already be integrated with Sprinklr before there is a participant to hand control to. See Omnichannel Handover Protocol.
In V3, all four control operations are served by one endpoint:
| Operation | Method | Path |
|---|---|---|
| Execute a thread control action | POST | /api/v3/thread |
This is the central design change from V2, which exposed four separate endpoints. In V3 the operation you want is selected by the action field in the request body, not by the URL you call.
action value | What it does | participantId |
|---|---|---|
acquireControl | Acquires control of the thread for the specified participant | Required |
passControl | Passes control of the thread from the current controller to the specified participant | Required |
releaseControl | Releases control of the thread currently held by the specified participant | Required |
checkControl | Returns the participant currently holding control of the thread | Not used |
participantId is not always an ID. The literal string Sprinklr is a valid participant value, and it means "the Sprinklr platform itself" — hand the conversation back to Sprinklr so a human agent, a rule, or a Sprinklr-native bot can take over.
This works in both directions:
- Sending
"participantId": "Sprinklr"passes or acquires control on Sprinklr's behalf. - Receiving
"controllingParticipantId": "Sprinklr"in a response tells you Sprinklr holds the thread.
Treat
controllingParticipantIdas an opaque string. Any client that assumes it is always a 24-character hex ID will break the first time control returns to Sprinklr.
Every request carries the same three-part address, plus the action:
| Concept | Field | Example |
|---|---|---|
| Which conversation | entityType + entityId | CASE + a case ID |
| Who the action concerns | participantId | A participant ID, or Sprinklr |
| What to do | action | acquireControl |
entityType and entityId are a pair. entityType names the kind of object the thread belongs to; entityId is that object's ID. In practice this is a case — entityType defaults to CASE.
Thread control can also be driven from the Sprinklr UI without any API calls, through the Rule Engine and Macros:
- Rule conditions live under Conditions on Sprinklr Thread Handover — for example the
Condition participant iscondition, which can test whether the controlling participant isSprinklr. - Rule actions live under Actions on Sprinklr Thread Handover —
Acquire Thread Control,Pass Thread Control, andRelease Thread Control. - On-demand rules are triggered manually through Case Macros, applied from the Details Pane in Engagement Dashboards or Agent Console.
Use this API when control decisions are made by your system — an external bot deciding it cannot answer, an orchestration layer routing between bots, or a test harness. Use the Rule Engine when the decision is made by Sprinklr-side conditions on the case.
The two approaches operate on the same underlying thread state, so a rule and an API call can contend for the same thread.
Facebook · WhatsApp · Twitter · Instagram · Line · Kakao Talk · Apple Business Chat · Google RCS · WeChat · Sina Weibo · Viber · Sprinklr LiveChat
https://api3.sprinklr.com/{env}/api/v3/threadReplace {env} with your assigned environment identifier: prod, prod0, prod2, prod3, prod4, prod5, prod6, prod8, prod11, prod12, prod15, prod16, prod17, prod18, prod19, prod21, or prod25.
See APIs | Sprinklr Developer Portal for the published environment list.
Make the base URL a single configuration value so moving between environments is a configuration change, not a code change.
Environment-scoped credentials. API keys and access tokens are scoped to a single environment. A key generated for
prod2returns401 Unauthorizedagainstprod19even when the request is otherwise perfectly formed. When you get an unexpected401, verify the environment before debugging the payload.
All calls are authenticated with OAuth 2.0. See API Overview for portal registration, API key and secret generation, and the Authorize flow. From release 26.1 onward, generate credentials through Developer Tools inside the Sprinklr platform; keys created through the legacy process remain valid.
| Header | Value | Purpose | Required on |
|---|---|---|---|
Authorization | Bearer {{accessToken}} | Authenticates the user with the server | All requests |
Key | {{apiKey}} | Authenticates the application with the server | All requests |
Content-Type | application/json | Declares the request body media type | All requests |
Accept | application/json | Declares the acceptable response type | Recommended |
Both Authorization and Key are required on every call. Sending only one returns 401.
| Credential | Lifetime |
|---|---|
| Authorization code | 10 minutes |
| API key | No expiry |
| Access token | 30 days by default |
One token pair per API key. Generating a new access token for an API key invalidates the previously issued token and refresh token for that key. If two services share one key and both refresh, they will knock each other offline. Give each service its own key.
Credential hygiene. Never commit an access token or API key to source control, never paste one into a ticket or chat, and never log the
AuthorizationorKeyheader values. Every credential on this page is a placeholder.
POST https://api3.sprinklr.com/{env}/api/v3/threadThis API differentiates behaviour entirely through the request body. The action field determines which control operation is performed.
| Parameter | Type | Required | Description |
|---|---|---|---|
action | String | Yes | The thread control action to perform. One of acquireControl, passControl, releaseControl, checkControl |
entityType | String | Yes | The entity type the thread belongs to. Use CASE |
entityId | String | Yes | Unique identifier of the entity or thread the action applies to. For CASE, the case ID |
participantId | String | Conditional | The participant performing or receiving the control action. Required for acquireControl, passControl, and releaseControl. Not used for checkControl. Accepts a participant ID or the literal Sprinklr |
action | entityType | entityId | participantId |
|---|---|---|---|
acquireControl | Required | Required | Required |
passControl | Required | Required | Required |
releaseControl | Required | Required | Required |
checkControl | Required | Required | Not used |
Validate
participantIdper action in your own client. Because it is unused bycheckControl, it is optional at the schema level — so schema validation alone will not catch apassControlrequest that omits it. Only the server will.
| Action | Description | What participantId names |
|---|---|---|
passControl | Pass control of a conversation between chatbots, and from chatbot to Sprinklr and vice versa, depending on the business use case | The participant to whom you are passing control |
acquireControl | Allows a participant to take control of the conversation | The participant who is taking control |
releaseControl | Releases control of a thread from a participant back to the primary participant | The participant who currently holds control |
checkControl | Returns the participant ID of the participant holding control of the conversation. Read-only | Not used |
Note the semantic asymmetry — this is the most common source of confusion. For
passControlandacquireControl,participantIdnames the destination of control. ForreleaseControl, it names the current holder — the participant giving control up. Sending the wrong side of that relationship produces a request that is structurally valid and semantically wrong.
action values are camelCase and exact: acquireControl, passControl, releaseControl, checkControl. Define them as constants in your own code — a typo such as aquireControl or passcontrol reaches the server and returns 400.
The examples below use one case and two participants, so they read as a single end-to-end sequence:
| Placeholder | Stands for |
|---|---|
0000000000000000000000c1 | The case ID (entityId) |
0000000000000000000000a1 | Bot A |
0000000000000000000000b2 | Bot B |
Request
curl -X POST \
'https://api3.sprinklr.com/{env}/api/v3/thread' \
-H 'Authorization: Bearer {{accessToken}}' \
-H 'Key: {{apiKey}}' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"action": "acquireControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1",
"participantId": "0000000000000000000000a1"
}'Response
{
"data": {
"controllingParticipantId": "0000000000000000000000a1"
},
"errors": []
}Bot A now holds the thread.
Pass the thread from Bot A to Bot B.
Request
curl -X POST \
'https://api3.sprinklr.com/{env}/api/v3/thread' \
-H 'Authorization: Bearer {{accessToken}}' \
-H 'Key: {{apiKey}}' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"action": "passControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1",
"participantId": "0000000000000000000000b2"
}'Response
{
"data": {
"controllingParticipantId": "0000000000000000000000b2"
},
"errors": []
}Passing control to the participant that already holds it is a no-op. It returns
200with the samecontrollingParticipantIdand no error — indistinguishable from a real transfer. Compare against the previous value if you need to know whether control actually moved.
checkControl is the only action that omits participantId.
Request
curl -X POST \
'https://api3.sprinklr.com/{env}/api/v3/thread' \
-H 'Authorization: Bearer {{accessToken}}' \
-H 'Key: {{apiKey}}' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"action": "checkControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1"
}'Response
{
"data": {
"controllingParticipantId": "0000000000000000000000b2"
},
"errors": []
}Request
curl -X POST \
'https://api3.sprinklr.com/{env}/api/v3/thread' \
-H 'Authorization: Bearer {{accessToken}}' \
-H 'Key: {{apiKey}}' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"action": "releaseControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1",
"participantId": "0000000000000000000000b2"
}'Response
{
"data": {
"controllingParticipantId": "Sprinklr"
},
"errors": []
}This is the response worth studying.
controllingParticipantIdis now the literal stringSprinklr, not an ID. Releasing control returned the thread to the primary participant. If your code parsescontrollingParticipantIdas a hex ID, or uses it as a foreign key into a participant table, this response is where it fails.
All four actions return the same shape: HTTP 200 with a data object containing a single field.
| Field | Type | Description |
|---|---|---|
data.controllingParticipantId | String | Participant ID of the participant now holding control — a participant ID, or the literal Sprinklr |
errors | Array | Array of error objects. Empty on success |
metadata | Object | Response metadata. Treat as optional and parse defensively |
Every write is also a read. Because every action returns the post-action controlling participant,
acquireControl,passControl, andreleaseControleach double as acheckControl. You rarely need a follow-up read to confirm the result of a write — the write already told you. UsecheckControlwhen you need the current holder without changing it.
When errors is non-empty, each entry has this shape. All three fields are present.
| Field | Type | Description |
|---|---|---|
id | String | Error identifier |
code | Integer | Numeric error code |
message | String | Error key, for example case.not.found |
Error responses return data as null:
{
"data": null,
"errors": [
{
"id": "0000000000000000000000e1",
"code": 404,
"message": "case.not.found"
}
],
"metadata": {}
}Illustrative example.
Always inspect the
errorsarray, not just the HTTP status. Themessagefield is a dotted key suitable for branching. Do not branch on human-readable text.
| HTTP code | Scenario |
|---|---|
200 OK | Success. The action was applied, or for checkControl, the current holder was read |
400 Bad Request | Malformed body, unknown action value, or a required field missing |
401 Unauthorized | Invalid or missing Authorization token or Key header |
403 Forbidden | Caller lacks permission for the thread-control action |
404 Not Found | The entity named by entityType and entityId does not exist |
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized with all headers present | Credentials belong to a different environment | Confirm the key and token were generated for the environment in the base URL |
401 after previously working | Access token expired (30-day default), or a second token was generated for the same API key | Refresh the token; give each service its own key |
401 and only one auth header sent | Both Authorization and Key are required | Send both |
400 on a passControl call | participantId omitted — schema validation will not catch it | Add participantId; validate per-action in your client |
400 with a plausible-looking action | A typo in the action value | Check exact spelling and camelCase: acquireControl, passControl, releaseControl, checkControl |
404 Not Found | entityId does not exist, or entityType is wrong for that ID | Confirm the case ID; use CASE |
403 Forbidden | Permission gap, not a malformed request | Check the user's permissions on the entity |
| Control appears not to change | Control was passed to the participant that already held it | Compare controllingParticipantId before and after; a no-op returns the same value with no error |
controllingParticipantId fails to parse as an ID | The value is the literal string Sprinklr | Treat controllingParticipantId as an opaque string |
| A rule reverted your change moments later | The Rule Engine is also acting on this thread | Check for on-demand or automated thread-handover rules on the case |
| V2 | V3 |
|---|---|
POST /api/v2/thread/acquire-control | POST /api/v3/thread with "action": "acquireControl" |
POST /api/v2/thread/pass-control | POST /api/v3/thread with "action": "passControl" |
POST /api/v2/thread/release-control | POST /api/v3/thread with "action": "releaseControl" |
POST /api/v2/thread/get-controlling-participant | POST /api/v3/thread with "action": "checkControl" |
| V2 field | V3 field | Change |
|---|---|---|
| — | action | New in V3. Carries the routing decision that the URL used to carry |
entityType | entityType | None |
entityId | entityId | None |
participantId | participantId | None |
This is the migration in one diff. Change the URL, add one field:
- POST https://api3.sprinklr.com/{env}/api/v2/thread/acquire-control
+ POST https://api3.sprinklr.com/{env}/api/v3/thread
{
+ "action": "acquireControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1",
"participantId": "0000000000000000000000a1"
}| V2 | V3 | Change |
|---|---|---|
data.controllingParticipantId | data.controllingParticipantId | None |
errors | errors | None |
The response contract is compatible with V2 for every action.
This is the migration's genuine risk, and it is invisible in the field mapping.
In V2, the action lived in the URL. A wrong or misspelled action produced a wrong URL, and the platform returned 404 Not Found at the routing layer — before any business logic ran. Your monitoring saw a 404 on a path you could read straight out of the log line.
In V3, the action lives in the body. A misspelled action now reaches a valid, existing endpoint and fails inside the handler. You see a 400, not a 404, and the log line shows POST /api/v3/thread — identical to every successful call. The URL no longer tells you which operation was attempted.
Consequences to plan for:
- Alerting on URL patterns stops working. If you alert or dashboard on
/thread/pass-controlversus/thread/release-control, those signals collapse into one path. Log theactionvalue explicitly on every call. 404and400semantics change. In V2,404usually meant "bad URL." In V3,404means "the case does not exist" — a genuine business condition — and400covers the malformed-action case. Retry and alerting logic keyed to those codes needs review.- Client-side action validation is now yours. Add an enum or constant set in your own code.
Your bot is the primary responder on WhatsApp. It detects an intent it cannot handle and needs a Sprinklr agent.
{
"action": "passControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1",
"participantId": "Sprinklr"
}The response confirms "controllingParticipantId": "Sprinklr". Your bot should stop replying on this thread until it sees control return.
A new case opens and your bot should own the first turns.
{
"action": "acquireControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1",
"participantId": "0000000000000000000000a1"
}Your bot completed its flow and should relinquish the thread rather than hand it to a named destination.
{
"action": "releaseControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1",
"participantId": "0000000000000000000000a1"
}Note the argument: participantId is the bot giving up control, not the recipient. The response returns "Sprinklr" — the primary participant that now holds the thread.
releaseControlversuspassControltoSprinklr. Both end with Sprinklr holding the thread, so it is easy to treat them as interchangeable. They are not:releaseControlsays "I am done" and takes your own ID;passControlsays "you take it" and takes the destination's ID. UsereleaseControlwhen your bot is finishing normally, andpassControlwhen you are making a routing decision toward a specific destination.
To be certain your bot never talks over an agent, call checkControl before sending:
{
"action": "checkControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1"
}Send only if data.controllingParticipantId equals your own participant ID.
This costs one extra round trip per message. For high-volume bots, prefer to track control state from the responses you already receive — every write action returns the new holder — and use checkControl only to resynchronise after an error or a gap.
A triage bot classifies the request and routes it to a specialist bot. Control passes between chatbots, and from chatbot to Sprinklr and vice versa.
{
"action": "passControl",
"entityType": "CASE",
"entityId": "0000000000000000000000c1",
"participantId": "0000000000000000000000b2"
}Your bot crashed mid-conversation and does not know whether it still holds control.
Call checkControl on each open case and branch on the result. This is the cheapest way to rebuild control state without mutating anything — it is the only action of the four that cannot change the thread.
A Sprinklr on-demand rule, triggered by an agent applying a Case Macro, took control away from your bot. Your bot did not make that call, so nothing in its own request log explains the change.
Poll checkControl, or check control before each send, and treat a controllingParticipantId that is neither your ID nor your last known value as an external hand-off.
The four actions form a natural round trip on a single case:
checkControl— record the starting holderacquireControlwith Bot A — assertcontrollingParticipantIdis Bot ApassControlto Bot B — assert it is Bot BcheckControl— assert it is still Bot BreleaseControlwith Bot B — assert it isSprinklr
Because every action returns the resulting holder, each step is self-asserting; no separate read is needed between steps.
Treat controllingParticipantId as an opaque string
- It is not always a 24-character hex ID. The literal
Sprinklris a valid value and is whatreleaseControlreturns. - Do not use it as a database foreign key without handling the sentinel.
- Do not regex-validate it as hex.
- The same applies on the request side:
Sprinklris a validparticipantIdto send.
action correctness is your responsibility
- Values are camelCase and case-sensitive:
acquireControl,passControl,releaseControl,checkControl. - Define them as constants once. A typo reaches the server and returns
400.
Know which side participantId names
passControlandacquireControl— the participant that receives control.releaseControl— the participant that currently holds control and is giving it up.
Every write is also a read
- All four actions return the post-action
controllingParticipantId. You almost never need a follow-upcheckControlafter a write.
checkControl is a read behind a POST
- It does not modify the thread, but it is still an HTTP
POST: not safe, not cacheable, and not retryable by any intermediary that assumesGETsemantics. - Do not put it behind an HTTP cache.
Plan for concurrency
- The Rule Engine, Macros applied by agents, and other API clients act on the same thread state. A control decision can be overridden a moment after you make it.
- Check control immediately before acting, not once at the start of a long flow.
- No optimistic-concurrency mechanism is exposed on this endpoint, so a lost update is possible and not detectable from the response alone.
Repeated actions are silent no-ops
- Re-sending
acquireControlfor a participant that already holds control returns200with the samecontrollingParticipantIdand no error. Compare against the previous value if you need to know whether anything moved.
Log the action value
- The URL no longer distinguishes operations, and without the logged action your logs cannot tell you what was attempted.