# Thread Control API V3

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](https://www.sprinklr.com/help/articles/api/omnichannel-handover-protocol/64832193723d925979db8cd4).


## Overview

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.

### The four actions

| `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** |


### The `Sprinklr` sentinel participant

`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 `controllingParticipantId` as an opaque string.** Any client that assumes it is always a 24-character hex ID will break the first time control returns to Sprinklr.


### The three things that identify a control action

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`.

### When to use this API

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 is` condition, which can test whether the controlling participant is `Sprinklr`.
- Rule actions live under **Actions on Sprinklr Thread Handover** — `Acquire Thread Control`, `Pass Thread Control`, and `Release 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.


### Supported messaging channels

Facebook · WhatsApp · Twitter · Instagram · Line · Kakao Talk · Apple Business Chat · Google RCS · WeChat · Sina Weibo · Viber · Sprinklr LiveChat

## Base URL

```
https://api3.sprinklr.com/{env}/api/v3/thread
```

Replace `{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](https://dev.sprinklr.com/apis) 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 `prod2` returns `401 Unauthorized` against `prod19` even when the request is otherwise perfectly formed. When you get an unexpected `401`, verify the environment before debugging the payload.


## Authentication

All calls are authenticated with OAuth 2.0. See [API Overview](https://dev.sprinklr.com/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.

### Required headers

| 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 lifetimes

| 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 `Authorization` or `Key` header values. Every credential on this page is a placeholder.


## Execute a thread control action

```
POST https://api3.sprinklr.com/{env}/api/v3/thread
```

This API differentiates behaviour **entirely through the request body**. The `action` field determines which control operation is performed.

### Request body parameters

| 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` |


### Per-action requirements

| `action` | `entityType` | `entityId` | `participantId` |
|  --- | --- | --- | --- |
| `acquireControl` | Required | Required | Required |
| `passControl` | Required | Required | Required |
| `releaseControl` | Required | Required | Required |
| `checkControl` | Required | Required | Not used |


> **Validate `participantId` per action in your own client.** Because it is unused by `checkControl`, it is optional at the schema level — so schema validation alone will not catch a `passControl` request that omits it. Only the server will.


### What the four actions mean

| 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 `passControl` and `acquireControl`, `participantId` names the **destination** of control. For `releaseControl`, 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 case-sensitive

`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`.

## Worked examples

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 |


### Acquire control

**Request**

```bash
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**

```json
{
  "data": {
    "controllingParticipantId": "0000000000000000000000a1"
  },
  "errors": []
}
```

Bot A now holds the thread.

### Pass control

Pass the thread from Bot A to Bot B.

**Request**

```bash
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**

```json
{
  "data": {
    "controllingParticipantId": "0000000000000000000000b2"
  },
  "errors": []
}
```

> **Passing control to the participant that already holds it is a no-op.** It returns `200` with the same `controllingParticipantId` and no error — indistinguishable from a real transfer. Compare against the previous value if you need to know whether control actually moved.


### Check control

`checkControl` is the only action that omits `participantId`.

**Request**

```bash
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**

```json
{
  "data": {
    "controllingParticipantId": "0000000000000000000000b2"
  },
  "errors": []
}
```

### Release control

**Request**

```bash
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**

```json
{
  "data": {
    "controllingParticipantId": "Sprinklr"
  },
  "errors": []
}
```

> **This is the response worth studying.** `controllingParticipantId` is now the literal string `Sprinklr`, not an ID. Releasing control returned the thread to the primary participant. If your code parses `controllingParticipantId` as a hex ID, or uses it as a foreign key into a participant table, this response is where it fails.


## Response format

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`, and `releaseControl` each double as a `checkControl`. You rarely need a follow-up read to confirm the result of a write — the write already told you. Use `checkControl` when you need the current holder *without* changing it.


### The error object

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`:

```json
{
  "data": null,
  "errors": [
    {
      "id": "0000000000000000000000e1",
      "code": 404,
      "message": "case.not.found"
    }
  ],
  "metadata": {}
}
```

*Illustrative example.*

> **Always inspect the `errors` array, not just the HTTP status.** The `message` field is a dotted key suitable for branching. Do not branch on human-readable text.


## Status codes

| 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 |


### Troubleshooting

| 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 |


### Endpoint mapping

| 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"` |


### Request field mapping

| 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:

```diff
- 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"
  }
```

### Response parsing does not need to change

| V2 | V3 | Change |
|  --- | --- | --- |
| `data.controllingParticipantId` | `data.controllingParticipantId` | None |
| `errors` | `errors` | None |


The response contract is compatible with V2 for every action.

### The error-handling change to plan for

> **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:

1. **Alerting on URL patterns stops working.** If you alert or dashboard on `/thread/pass-control` versus `/thread/release-control`, those signals collapse into one path. **Log the `action` value explicitly on every call.**
2. **`404` and `400` semantics change.** In V2, `404` usually meant "bad URL." In V3, `404` means "the case does not exist" — a genuine business condition — and `400` covers the malformed-action case. Retry and alerting logic keyed to those codes needs review.
3. **Client-side action validation is now yours.** Add an enum or constant set in your own code.


### Hand off from your bot to a human agent

Your bot is the primary responder on WhatsApp. It detects an intent it cannot handle and needs a Sprinklr agent.

```json
{
  "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.

### Take control at the start of a conversation

A new case opens and your bot should own the first turns.

```json
{
  "action": "acquireControl",
  "entityType": "CASE",
  "entityId": "0000000000000000000000c1",
  "participantId": "0000000000000000000000a1"
}
```

### Return the thread to the primary participant

Your bot completed its flow and should relinquish the thread rather than hand it to a named destination.

```json
{
  "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.

> **`releaseControl` versus `passControl` to `Sprinklr`.** Both end with Sprinklr holding the thread, so it is easy to treat them as interchangeable. They are not: `releaseControl` says *"I am done"* and takes your own ID; `passControl` says *"you take it"* and takes the destination's ID. Use `releaseControl` when your bot is finishing normally, and `passControl` when you are making a routing decision toward a specific destination.


### Guard every outbound message

To be certain your bot never talks over an agent, call `checkControl` before sending:

```json
{
  "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.

### Route between bots

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.

```json
{
  "action": "passControl",
  "entityType": "CASE",
  "entityId": "0000000000000000000000c1",
  "participantId": "0000000000000000000000b2"
}
```

### Reconcile state after a failure

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.

### Detect a Rule Engine hand-off

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.

### Build a regression harness

The four actions form a natural round trip on a single case:

1. `checkControl` — record the starting holder
2. `acquireControl` with Bot A — assert `controllingParticipantId` is Bot A
3. `passControl` to Bot B — assert it is Bot B
4. `checkControl` — assert it is still Bot B
5. `releaseControl` with Bot B — assert it is `Sprinklr`


Because every action returns the resulting holder, each step is self-asserting; no separate read is needed between steps.

## Best practices

**Treat `controllingParticipantId` as an opaque string**

- It is **not** always a 24-character hex ID. The literal `Sprinklr` is a valid value and is what `releaseControl` returns.
- 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: `Sprinklr` is a valid `participantId` to 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**

- `passControl` and `acquireControl` — 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-up `checkControl` after 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 assumes `GET` semantics.
- 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 `acquireControl` for a participant that already holds control returns `200` with the same `controllingParticipantId` and 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.