# Create a chat session via handoff

> POST /v2.1/chat/handoff · Chat conversations API

Source: https://developer.kustomer.com/api/chat/create-a-chat-session-via-handoff

```http
POST https://{orgname}.api.kustomerapp.com/v2.1/chat/handoff
Authorization: Bearer <API key>
```

## Authorization

Requires any one of these roles: `org.permission.conversation.create`, `org.permission.message.create`. The API key or user must hold at least one of them.

Import an existing external conversation into Kustomer chat by creating a
session and persisting historical messages in one call.

Identity is supplied in the JSON body (`customerId`, optional `trackingId`), not
via a `/c/` tracking JWT. Unlike customer SDK handoff
(`POST /c/v2.1/chat/handoff`), this API-token route does not require a tracking
JWT; optional `trackingId` may be supplied on the request (see
`CreateChatHandoffRequest`).

Messages are validated and sorted by `date`. The array must include at least
one inbound (`direction: in`) and one outbound (`direction: out`) message
(2–100 messages total). `from` is applied to all outbound messages. Optional
`externalId` is stored on the session custom attributes for linking back to the
external thread. When `active` is `false`, the session is created closed (and
CSAT may be offered when enabled for the org).

Historical messages are inserted without PubNub fan-out during import, then
mirrored to CRM conversation messages.

## About Chat conversations

The server-side chat API lets your backend take part in Kustomer chat with a regular API key. It can open chat sessions for a customer, post messages as the customer, an agent or an assistant, show typing, import bot conversations and record satisfaction ratings.

A chat session is a Kustomer conversation on the chat channel, seen from the chat side. Its ID is the conversation ID, so the same conversation appears in the agent desktop and in the customer's chat widget or app, and you can read it with the [conversations API](https://developer.kustomer.com/api/core-resources#conversations) too. Sessions are usually created by the [chat SDKs](https://developer.kustomer.com/docs/chat) when a customer starts a chat. These v2.1 endpoints do the same jobs from your server, for custom chat front ends, messaging bridges and bots that answer before a human does. Anything you post is delivered in realtime to the customer's open chat on every device.

A session stays active until the chat is ended or deleted. Listing with `active=true` returns only active sessions that have at least one message.

#### Identity: customerId and trackingId

These endpoints never use a chat key or a customer's chat login token. Every request names the customer in `customerId`, and Kustomer checks it against the session: a mismatch fails with `403`. `trackingId` is the chat SDK's ID for a visitor's browser or device. It's optional, but when a session has one, any `trackingId` you send must match. Creating a session without one reuses the tracking ID from the customer's latest chat session, if there is one; handoff imports never set one.

Use a server-side API key with the roles each endpoint lists, never a chat key, which must hold only `org.tracking`.

#### Who a message is from

| Endpoint | Appears as |
| --- | --- |
| `POST /v2.1/chat/messages` | The customer (`direction: in`). Returns `202 Accepted`; the message is processed asynchronously. |
| `POST /v2.1/chat/assistant/messages` | The customer, replying inside an assistant (bot) flow, so the assistant continues from where it is. |
| `POST /v2.1/chat/outbound/messages` | An agent: `from` is the Kustomer user ID. Add `auto: true` for bot or AI replies so they don't count as a human first response. |
| `POST /v2.1/chat/outbound/assistant/messages` | An assistant line, attributed to `from`. Send exactly one of `body`, `meta.template` or `attachments`. |

Message bodies are up to 10,240 characters. Typing indicators are ephemeral and never stored. Your service owns their lifecycle: sending a message doesn't hide one, so send `typing: false` when you're done, and resend `typing: true` about every three seconds for long answers.

#### Which endpoint to use

| To | Call |
| --- | --- |
| Open a chat for a customer, optionally starting an assistant | [`POST /v2.1/chat/sessions`](https://developer.kustomer.com/api/chat/create-a-chat-session) |
| Import a conversation your own bot already had | [`POST /v2.1/chat/handoff`](https://developer.kustomer.com/api/chat/create-a-chat-session-via-handoff) |
| List a customer's or visitor's chats | [`GET /v2.1/chat/sessions`](https://developer.kustomer.com/api/chat/list-chat-sessions) |
| Read a chat's messages | [`GET /v2.1/chat/sessions/{id}/messages`](https://developer.kustomer.com/api/chat/list-messages-for-a-chat-session) |
| Post as the customer | [`POST /v2.1/chat/messages`](https://developer.kustomer.com/api/chat/create-an-inbound-chat-message) or, in an assistant flow, [`POST /v2.1/chat/assistant/messages`](https://developer.kustomer.com/api/chat/create-an-inbound-assistant-chat-message) |
| Post as an agent or bot | [`POST /v2.1/chat/outbound/messages`](https://developer.kustomer.com/api/chat/create-an-outbound-agent-chat-message) |
| Post as an assistant | [`POST /v2.1/chat/outbound/assistant/messages`](https://developer.kustomer.com/api/chat/create-an-outbound-assistant-chat-message) |
| Show or hide typing | [`POST /v2.1/chat/outbound/typing`](https://developer.kustomer.com/api/chat/show-or-hide-agent-ai-typing-indicator) |
| Record a CSAT rating or comment | [`PUT /v2.1/chat/satisfaction-responses/{id}`](https://developer.kustomer.com/api/chat/update-a-chat-satisfaction-response) |

A handoff takes 2 to 100 messages, including at least one inbound and one outbound, and sorts them by `date`; `active: false` creates the conversation already closed. When you pass `assistant` on session create, Kustomer posts the assistant's greeting, and if that fails the session is still created. Session lists are paginated, newest first by `lastMessageAt`, and don't include messages. The CSAT response must already exist, created by Kustomer for the chat's survey; this call fills it in, and sending `answers` marks it commented rather than rated.

Related: [Server-side chat API](https://developer.kustomer.com/docs/chat/server-chat-api), [How chat works](https://developer.kustomer.com/docs/chat/realtime-architecture), [Customer identity](https://developer.kustomer.com/docs/chat/identity), [Conversations](https://developer.kustomer.com/api/core-resources#conversations)

### Recipes

#### 1. Answer a customer's chat from your own bot

Reply in a live chat from a bot or AI service you host, with a typing indicator while it works, so the customer sees the answer in realtime.

**Step 1.** Hear about new chat messages with an [outbound webhook](https://developer.kustomer.com/docs/events/outbound-webhooks) on message creation, or call your service from a [workflow](https://developer.kustomer.com/docs/workflows). Act only on inbound chat messages, and take the conversation ID (the session ID) and customer ID from the event. Use a dedicated Kustomer user for your bot as `from` in the next calls.

**Step 2.** [`POST /v2.1/chat/outbound/typing`](https://developer.kustomer.com/api/chat/show-or-hide-agent-ai-typing-indicator.md). Show the typing indicator. `ai: true` shows the AI indicator instead of the agent one. Typing is never stored, and for long answers you must resend `typing: true` about every three seconds.

```bash title="cURL"
curl --request POST "https://api.kustomerapp.com/v2.1/chat/outbound/typing" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"customerId\": \"$CUSTOMER_ID\", \"session\": \"$SESSION_ID\", \"from\": \"$BOT_USER_ID\", \"typing\": true, \"ai\": true}"
```

```bash title="kusty"
kusty api POST /v2.1/chat/outbound/typing \
  --field customerId=$CUSTOMER_ID --field session=$SESSION_ID --field from=$BOT_USER_ID \
  --field typing:=true --field ai:=true
```

**Step 3.** [`POST /v2.1/chat/outbound/messages`](https://developer.kustomer.com/api/chat/create-an-outbound-agent-chat-message.md). Post the reply. `auto: true` marks it as automated, so it doesn't count as the first human response in reporting. `customerId` must be the session's customer, or the call fails with `403`. Bodies are up to 10,240 characters.

```bash title="cURL"
curl --request POST "https://api.kustomerapp.com/v2.1/chat/outbound/messages" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "customerId": "'"$CUSTOMER_ID"'",
    "session": "'"$SESSION_ID"'",
    "from": "'"$BOT_USER_ID"'",
    "body": "Your order shipped this morning.",
    "auto": true
  }'
```

```bash title="kusty"
kusty api POST /v2.1/chat/outbound/messages \
  --field customerId=$CUSTOMER_ID --field session=$SESSION_ID --field from=$BOT_USER_ID \
  --field "body=Your order shipped this morning." --field auto:=true
```

**Step 4.** [`POST /v2.1/chat/outbound/typing`](https://developer.kustomer.com/api/chat/show-or-hide-agent-ai-typing-indicator.md). Hide the indicator. Sending a message doesn't hide it, so always finish with `typing: false`, including when your service fails.

```bash title="cURL"
curl --request POST "https://api.kustomerapp.com/v2.1/chat/outbound/typing" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"customerId\": \"$CUSTOMER_ID\", \"session\": \"$SESSION_ID\", \"from\": \"$BOT_USER_ID\", \"typing\": false}"
```

```bash title="kusty"
kusty api POST /v2.1/chat/outbound/typing \
  --field customerId=$CUSTOMER_ID --field session=$SESSION_ID --field from=$BOT_USER_ID --field typing:=false
```

#### 2. Hand a bot conversation over to your agents

When a chat that started in your own bot needs a human, import the whole exchange into Kustomer so agents pick it up with the history.

**Step 1.** [`GET /v1/customers/email={email}`](https://developer.kustomer.com/api/core-resources/get-customer-by-email.md). Find the customer by the email your bot collected. A `404` means they don't exist yet.

```bash title="cURL"
curl "https://api.kustomerapp.com/v1/customers/email=$EMAIL" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY"
```

```bash title="kusty"
kusty api GET "/v1/customers/email=$EMAIL" --fields id
```

**Step 2.** [`POST /v1/customers`](https://developer.kustomer.com/api/core-resources/create-customer.md). Only if the lookup returned `404`: create the customer, and use the new `data.id`.

```bash title="cURL"
curl --request POST "https://api.kustomerapp.com/v1/customers" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"name\": \"$NAME\", \"emails\": [{\"email\": \"$EMAIL\"}]}"
```

```bash title="kusty"
kusty api POST /v1/customers --field "name=$NAME" --field emails.0.email=$EMAIL --fields id
```

**Step 3.** [`POST /v2.1/chat/handoff`](https://developer.kustomer.com/api/chat/create-a-chat-session-via-handoff.md). Import the exchange. `messages` takes 2 to 100 messages with at least one `in` and one `out`, and Kustomer sorts them by `date`; `from` labels every outbound line. `active: true` creates the conversation open, ready for routing. Put your bot's thread ID in `externalId` to link back to it. The response's `data.id` is the new conversation ID.

```bash title="cURL"
curl --request POST "https://api.kustomerapp.com/v2.1/chat/handoff" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY" \
  --header "Content-Type: application/json" \
  --data @handoff.json
```

```bash title="kusty"
# handoff.json: {"customerId", "active": true, "from": "Acme Bot", "externalId", "messages": [{"date", "direction", "body"}, ...]}
jq --arg c "$CUSTOMER_ID" '.customerId = $c' handoff.json \
  | kusty api POST /v2.1/chat/handoff --data @- --fields id
```

> A handoff doesn't need a tracking JWT. Send `trackingId` to tie the imported chat to a browser or device. If you leave it out, Kustomer may reuse the customer's latest chat session tracking ID, so omitting it doesn't guarantee an untracked session. Send `active: false` instead to keep a closed record of a conversation your bot finished on its own.

#### 3. Start a proactive chat without duplicating an open one

Reach out to a customer in chat, for example about a delayed order, reusing their active chat if they have one instead of opening a second conversation.

**Step 1.** [`GET /v2.1/chat/sessions`](https://developer.kustomer.com/api/chat/list-chat-sessions.md). List the customer's active sessions. They come back newest first by `lastMessageAt`, and only sessions with at least one message count as active. If there's one, use its `id` and skip the next step.

```bash title="cURL"
curl "https://api.kustomerapp.com/v2.1/chat/sessions?customerId=$CUSTOMER_ID&active=true" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY"
```

```bash title="kusty"
kusty api GET /v2.1/chat/sessions --query customerId=$CUSTOMER_ID --query active=true \
  | jq -r '.data[0].id // empty'
```

**Step 2.** [`POST /v2.1/chat/sessions`](https://developer.kustomer.com/api/chat/create-a-chat-session.md). Otherwise open a session. `title` names the conversation, and `brand` picks which brand's chat it belongs to. The response's `data.id` is the session and conversation ID.

```bash title="cURL"
curl --request POST "https://api.kustomerapp.com/v2.1/chat/sessions" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"customerId\": \"$CUSTOMER_ID\", \"title\": \"Your order is delayed\"}"
```

```bash title="kusty"
kusty api POST /v2.1/chat/sessions \
  --field customerId=$CUSTOMER_ID --field "title=Your order is delayed" --fields id
```

**Step 3.** [`POST /v2.1/chat/outbound/messages`](https://developer.kustomer.com/api/chat/create-an-outbound-agent-chat-message.md). Post the message as an agent; `from` is the Kustomer user it's attributed to. It's delivered to the customer's open chat on every device.

```bash title="cURL"
curl --request POST "https://api.kustomerapp.com/v2.1/chat/outbound/messages" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "customerId": "'"$CUSTOMER_ID"'",
    "session": "'"$SESSION_ID"'",
    "from": "'"$AGENT_USER_ID"'",
    "body": "Your order 4417 is running two days late. Reply here if you have any questions."
  }'
```

```bash title="kusty"
kusty api POST /v2.1/chat/outbound/messages \
  --field customerId=$CUSTOMER_ID --field session=$SESSION_ID --field from=$AGENT_USER_ID \
  --field "body=Your order 4417 is running two days late. Reply here if you have any questions."
```

#### 4. Export a customer's chat transcripts

Pull every chat a customer has had, with the messages, for example to answer a data access request or archive chats in another system.

**Step 1.** [`GET /v2.1/chat/sessions`](https://developer.kustomer.com/api/chat/list-chat-sessions.md). Page through the customer's sessions. Leave out `active` to get closed chats too. Sessions don't include messages.

```bash title="cURL"
curl "https://api.kustomerapp.com/v2.1/chat/sessions?customerId=$CUSTOMER_ID&pageSize=100" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY"
```

```bash title="kusty"
kusty api GET /v2.1/chat/sessions --query customerId=$CUSTOMER_ID --all | jq -r .data.id > sessions.txt
```

**Step 2.** [`GET /v2.1/chat/sessions/{id}/messages`](https://developer.kustomer.com/api/chat/list-messages-for-a-chat-session.md). Page through each session's messages. Each has `body`, `direction` (`in` from the customer, `out` from your team or a bot) and `createdAt`. Pass `fields` to return only the attributes you need, and sort by `createdAt` yourself before writing the transcript.

```bash title="cURL"
curl "https://api.kustomerapp.com/v2.1/chat/sessions/$SESSION_ID/messages?fields=body,direction,createdAt&pageSize=100" \
  --header "Authorization: Bearer $KUSTOMER_API_KEY"
```

```bash title="kusty"
while read -r s; do
  kusty api GET /v2.1/chat/sessions/$s/messages --query fields=body,direction,createdAt --all \
    | jq -c --arg s "$s" '{session: $s, at: .data.attributes.createdAt, dir: .data.attributes.direction, body: .data.attributes.body}'
done < sessions.txt > transcripts.jsonl
```

### Example object

A real record from [`POST /v2.1/chat/sessions`](https://developer.kustomer.com/api/chat/create-a-chat-session.md):

```json
{
  "type": "chat_session",
  "id": "5679792fbc351e1d007a2993",
  "attributes": {
    "trackingId": "55d7998f2d3f9f8800099aef",
    "createdAt": "2026-07-15T13:30:00.000Z",
    "isInAssistantMode": true,
    "preview": "How can I help you today?"
  },
  "relationships": {
    "org": {
      "data": {
        "type": "org",
        "id": "55d7998f2d3f9f8800099aed"
      }
    },
    "customer": {
      "data": {
        "type": "customer",
        "id": "55d7998f2d3f9f8800099aee"
      }
    }
  },
  "links": {
    "self": "/c/v1/chat/sessions/5679792fbc351e1d007a2993"
  }
}
```

Fields:

- `id` (string): The conversation ID. A chat session is a conversation on the chat channel, so the same ID works as `session` when you post messages and with [`GET /v1/conversations/{id}`](https://developer.kustomer.com/api/core-resources/get-conversation-by-id).
- `attributes.trackingId` (string): The chat SDK's ID for the visitor's browser or device. When a session has one, any `trackingId` you send for it must match. Create a session without one and Kustomer reuses the tracking ID from the customer's latest chat session, if there is one.
- `attributes.createdAt` (string (date-time)): When the session was created. Sessions are listed by `lastMessageAt`, newest first, not by this.
- `attributes.isInAssistantMode` (boolean): `true` when the session is in assistant (bot) mode. Send `isInAssistantMode: true` on create to start it that way.
- `attributes.preview` (string): A preview of the latest message. When you create a session with `assistant`, it can already show the assistant's greeting.
- `relationships.customer` (object): The customer the chat is with. Every message, typing and CSAT call for this session must send this ID as `customerId`, or it fails with `403`.
- `links.self` (string): The chat SDK's customer-facing route, which needs a customer chat token rather than an API key. From your server, use [`GET /v2.1/chat/sessions`](https://developer.kustomer.com/api/chat/list-chat-sessions) or the conversations API instead.

### Relationships

- Points to **Customers** via `relationships.customer`: The customer a chat session is with. A customer can have many chat sessions; list them with `GET /v2.1/chat/sessions?customerId=…`.

### Permissions

Fewest roles for a read-only integration: `org.permission.conversation.read`, `org.permission.message.read`. For every endpoint: `org.permission.message.create`, `org.permission.conversation.create`, `org.permission.conversation.read`, `org.permission.conversation.update`, `org.permission.message.read`.

## Request body

Content type: `application/json` (required)

- `customerId` (string, required): Required. Kustomer customer ID. The session is created for this customer (no tracking token).
- `active` (boolean, required): Required. When `true`, the conversation/session is created open; when `false`, it is created closed.
- `from` (string, required): Required. Identifies the sender of outbound messages (for example user id, agent name, or system identifier). Applied to all outbound messages (`direction: out`) in the handoff.
- `messages` (array<ChatHandoffMessage>, required): Historical messages to import. Must include at least one inbound (`direction: in`) and one outbound (`direction: out`) message. Maximum 100 messages. (min items 2; max items 100)
  - `date` (string<date-time>, required): Message timestamp (ISO 8601).
  - `direction` (string, required): `in` for customer/inbound lines; `out` for agent, bot, or system outbound lines. One of: `in`, `out`.
  - `body` (string, required): Message text body. (max length 10240)
- `externalId` (string): Optional external identifier associated with the session (stored in the session's `custom.externalId` attribute).
- `custom` (object): Custom attributes. Keys must use Kustomer custom-attribute suffix rules (for example `orderNumStr`, `priorityStr`, `chatSourceTree`).
- `brand` (string): Optional chat settings / widget brand id. When omitted, the org default brand from settings is used.
- `lang` (string): Optional language code applied to all imported messages.
- `subStatus` (string): Optional conversation substatus ObjectId. Treated as an open substatus when `active` is `true`, or a done substatus when `active` is `false`.
- `trackingId` (string): Optional tracking identity ObjectId to attach to the session. When omitted on the API-token route, Kustomer may reuse the customer's latest chat-session tracking id when one exists. The session may have no tracking identity only when that fallback finds none or the lookup fails — omitting the field does not by itself guarantee an untracked session.

Example:

```json
{
  "customerId": "55d7998f2d3f9f8800099aee",
  "active": true,
  "from": "external-bot",
  "externalId": "ext-thread-98765",
  "lang": "en",
  "messages": [
    {
      "date": "2026-03-18T15:00:00.000Z",
      "direction": "in",
      "body": "I need help with my order"
    },
    {
      "date": "2026-03-18T15:00:30.000Z",
      "direction": "out",
      "body": "Connecting you with an agent"
    }
  ]
}
```

## Responses

### 201

Created chat session with imported handoff messages

- `data` (object)
  - `type` (string)
  - `id` (string): 24-character hexadecimal ObjectId (case-insensitive). (pattern ^[A-Fa-f\d]{24}$)
  - `attributes` (object)
  - `relationships` (object)
  - `links` (object)

```json
{
  "data": {
    "type": "chat_session",
    "id": "5679792fbc351e1d007a2993",
    "attributes": {
      "createdAt": "2026-03-18T15:00:30.000Z",
      "preview": "Connecting you with an agent"
    },
    "relationships": {
      "org": {
        "data": {
          "type": "org",
          "id": "55d7998f2d3f9f8800099aed"
        }
      },
      "customer": {
        "data": {
          "type": "customer",
          "id": "55d7998f2d3f9f8800099aee"
        }
      }
    },
    "links": {
      "self": "/c/v1/chat/sessions/5679792fbc351e1d007a2993"
    }
  }
}
```

### 400

Bad Request — invalid request parameters or body

### 401

Unauthorized — authentication required or invalid token

### 403

Forbidden — insufficient permissions or session/customer mismatch

## Example request

```bash
curl --request POST \
  --url 'https://api.kustomerapp.com/v2.1/chat/handoff' \
  --header "Authorization: Bearer $KUSTOMER_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
  "customerId": "55d7998f2d3f9f8800099aee",
  "active": true,
  "from": "external-bot",
  "externalId": "ext-thread-98765",
  "lang": "en",
  "messages": [
    {
      "date": "2026-03-18T15:00:00.000Z",
      "direction": "in",
      "body": "I need help with my order"
    },
    {
      "date": "2026-03-18T15:00:30.000Z",
      "direction": "out",
      "body": "Connecting you with an agent"
    }
  ]
}'
```
