# REST API

> Chat with your intoCHAT agents, read their conversations, leads and knowledge sources, keep their contacts in sync, and subscribe webhooks from your own server or an automation tool: API keys, scopes, rate limits, errors, pagination and every endpoint with examples.

The REST API lets your own server talk to your agents. Send a message and get the agent's reply, read conversations and leads, create and update contacts, add or remove knowledge sources, and subscribe webhooks to an agent's events, with JSON over HTTPS. The [Zapier and Make apps](/en/docs/zapier-and-make) are built on it. A reply through the API is written by the same code as a reply in the chat widget, from the same knowledge and settings.

The API is for servers. It doesn't send CORS headers, so a web page on another site can't call it, and your API key must never appear in a browser or in your embed code. To show a chat on your site, use the [script tag](/en/docs/script-tag). To get events pushed to you as they happen, use [webhooks](/en/docs/webhooks).

## Which plans include the API

The API is included on **Starter**, **Pro** and **Enterprise**. On **Free** you can't create keys.

If an account moves back to Free, its keys stay in the list but every request gets a `403` error with the code `plan_required`. They work again as soon as the account is on Starter or higher.

Each reply an agent writes through the API counts as one message on your plan, exactly like a reply in the widget, and counts toward the agent's [monthly message cap](/en/docs/limits-and-access#monthly-message-cap) if you set one. Reading data, and adding or deleting sources, uses no messages. See [What counts as a message](/en/docs/plans-and-limits#what-counts-as-a-message).

## Create an API key

1. In the sidebar, under **Settings**, click **API keys**.
2. Under **Create a key**, give the key a **Name** you will recognize later, for example `CRM sync`.
3. Under **Access**, keep **Every scope, including ones added later**, or pick **Only the scopes I choose** and tick the ones this key needs. See [Scopes](#scopes).
4. Click **Create key**.
5. Click **Copy**, store the key where your server can read it, for example in an environment variable, and click **I've saved it**.

A key looks like `ic_live_` followed by 32 letters and digits. It is shown only once: intoCHAT keeps only a fingerprint of it, so nobody can show it to you again. If you lose it, revoke it and create a new one.

A key belongs to the account, not to the person who created it. It works for every agent in the account, within its scopes, and keeps working if that person leaves the team. An account can have up to 20 active keys.

Only the account owner and admins can create and revoke keys. Editors and viewers see the list of keys, with their first characters only. See [Team members and roles](/en/docs/team).

### The key list

Each key shows its name, its first characters (for example `ic_live_3f9A…`), its scopes, when it was created and when it was last used. **Last used** is updated at most once a minute.

### Revoke a key

Click **Revoke** next to the key and confirm. The key stops working from its next request, and this can't be undone. Revoke a key as soon as you think someone else may have it.

Revoking a key also deletes the webhooks it [subscribed](#subscribe-a-webhook), for example for Zapier or Make, with their delivery history: nothing could remove them otherwise. Webhooks you added on the **Leads** tab are not affected.

## Authentication

Send the key in the `Authorization` header of every request:

```bash
curl https://www.intochat.ai/api/v1/agents \
  -H "Authorization: Bearer ic_live_YOUR_KEY"
```

All endpoints are under `https://www.intochat.ai/api/v1`. Requests with a body send JSON with `Content-Type: application/json`, and every response is JSON, except a streamed chat reply. The API uses no cookies and no sign-in session: the key alone decides what a request may do.

A missing, malformed, unknown or revoked key gets a `401` error with the code `unauthorized`.

## Scopes

A scope lets a key use a group of endpoints. A key created with **Every scope, including ones added later** can use every endpoint, also ones added in the future.

| Scope | Allows |
| --- | --- |
| `agents:read` | [List agents](#list-agents) and [get an agent](#get-an-agent) |
| `chat` | [Send a message](#send-a-message) to an agent. Each reply counts as a message on your plan. |
| `conversations:read` | [List conversations](#list-conversations) and [get a conversation](#get-a-conversation) with its transcript |
| `leads:read` | [List leads](#list-leads), [list contacts](#list-contacts) and [get a contact](#get-a-contact), and the lead, form, booking and return events of [List events](#list-events) and [Get sample events](#get-sample-events) |
| `contacts:write` | [Create or update a contact](#create-or-update-a-contact), [change a contact](#change-a-contact) and [delete a contact](#delete-a-contact) |
| `sources:read` | [List sources](#list-sources) |
| `sources:write` | [Add a source](#add-a-source) and [delete a source](#delete-a-source), which also starts training |
| `webhooks:write` | [Subscribe a webhook](#subscribe-a-webhook) and [unsubscribe a webhook](#unsubscribe-a-webhook) |

`conversations:read` also covers the hand-off, live chat and new conversation events of [List events](#list-events).

A request to an endpoint the key has no scope for gets a `403` error with the code `insufficient_scope`. To change a key's scopes, create a new key and revoke the old one.

## Rate limits

Each key can make 60 requests a minute, across all endpoints. Every response that passed the key check carries these headers:

| Header | Value |
| --- | --- |
| `X-RateLimit-Limit` | `60` |
| `X-RateLimit-Remaining` | Requests left in the current minute |
| `X-RateLimit-Reset` | When the minute ends, in Unix seconds |

Over the limit, the request gets a `429` error with the code `rate_limited` and a `Retry-After` header with the seconds to wait.

Chat has two more limits that don't depend on the key: the plan's messages, and an agent answers at most 1,000 messages an hour, widget and API together. Over that, a chat request gets a `429` error with the code `agent_busy`.

## Errors

Every error has the same shape:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "This API key doesn't have the \"chat\" scope."
  }
}
```

Use `code` in your code; `message` explains it for a person and may change.

| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `invalid_request` | The request is missing something or has a wrong value. The message says which. |
| `401` | `unauthorized` | No key, or the key is malformed, unknown or revoked. |
| `403` | `insufficient_scope` | The key doesn't have the scope this endpoint needs. |
| `403` | `plan_required` | The account is on Free. The API is included from Starter. |
| `403` | `message_limit_reached` | The account has used all the messages its plan includes this period. |
| `403` | `agent_cap_reached` | The agent reached the monthly message cap set under **Share**, **Limits and access**. |
| `403` | `agent_private` | The agent is private, so it answers only in the dashboard. See [Private agents](/en/docs/allowed-domains#private-agents). |
| `403` | `character_limit_exceeded` | A new source would take the agent past its plan's training characters. |
| `403` | `contact_limit_reached` | A new contact would take the agent past its plan's contacts per agent. |
| `404` | `not_found` | No such agent, conversation, source, contact or webhook in this account. |
| `409` | `conflict` | Another contact of the agent already has this external ID or email. |
| `429` | `rate_limited` | The key made more than 60 requests this minute. |
| `429` | `agent_busy` | The agent is answering too many messages right now. |
| `500` | `internal_error` | Something went wrong on our side. Try again. |
| `504` | `timeout` | The agent took too long to answer. |

An agent, conversation or source that belongs to another account gets `404`, the same as one that doesn't exist.

## Pagination

[List conversations](#list-conversations) and [list leads](#list-leads) return one page at a time, newest first. They take these query parameters:

| Parameter | Content |
| --- | --- |
| `limit` | Items per page, from 1 to 100. The default is 20. |
| `cursor` | The `nextCursor` of the previous page, to get the next one. |
| `since` | An ISO 8601 time, for example `2026-10-01T00:00:00Z`. Only items with activity at or after it are returned: a conversation with a message since then, or a lead that was saved or submitted again since then. |

A page looks like this:

```json
{
  "data": [ ],
  "hasMore": true,
  "nextCursor": "MjAyNi0xMC0wNFQwOTowMDowMC4wMDBafGNsdjEyMw"
}
```

Request the next page with `?cursor=` and that value, keeping the same `limit` and `since`, until `hasMore` is `false` and `nextCursor` is `null`. The order is fixed by creation time, so new activity while you page through doesn't make an item appear twice or go missing. To sync regularly, store the time you started the last sync and pass it as `since` next time.

## List agents

`GET /api/v1/agents` · scope `agents:read`

Every agent in the account, newest first.

```bash
curl https://www.intochat.ai/api/v1/agents \
  -H "Authorization: Bearer ic_live_YOUR_KEY"
```

```json
{
  "data": [
    {
      "id": "AGENT_ID",
      "name": "Acme Assistant",
      "private": false,
      "status": "trained",
      "trainedAt": "2026-10-02T09:00:00.000Z",
      "characters": 48210,
      "counts": { "conversations": 312, "messages": 2240, "leads": 41, "sources": 18 },
      "createdAt": "2026-09-01T09:00:00.000Z",
      "updatedAt": "2026-10-02T09:00:00.000Z"
    }
  ]
}
```

`status` is `untrained`, `training`, `trained` or `error` (the last training failed). `characters` is how many training characters the agent's trained sources use.

## Get an agent

`GET /api/v1/agents/{agentId}` · scope `agents:read`

One agent, in `data`, with the same fields as in [List agents](#list-agents).

## Send a message

`POST /api/v1/agents/{agentId}/chat` · scope `chat`

Sends one message to the agent and returns its reply. The agent answers as it does in the widget: from its knowledge and instructions, with your [API actions](/en/docs/api-actions), [custom buttons](/en/docs/custom-buttons), [chat forms](/en/docs/chat-forms), [booking](/en/docs/booking), [hand-off by email](/en/docs/handoff) and [live chat](/en/docs/live-chat) where you set them up.

| Field | Content |
| --- | --- |
| `message` | Required. The text, up to 10,000 characters. |
| `conversationId` | Optional. Continue a conversation you started through the API, with the `conversationId` of an earlier reply. |
| `sessionId` | Optional. Your own ID for the person you chat for, for example your user ID, up to 100 characters. The same value always continues the same conversation. You can also send the `sessionId` of an earlier reply. |
| `stream` | Optional. `true` streams the reply's text; see [Streaming](#streaming). The default is `false`. |
| `timeZone` | Optional. The person's IANA time zone, for example `Europe/Berlin`, for booking times. Without it, times are in UTC. |

Without `conversationId` and `sessionId`, every message starts a new conversation.

```bash
curl https://www.intochat.ai/api/v1/agents/AGENT_ID/chat \
  -H "Authorization: Bearer ic_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "message": "Do you ship to Switzerland?", "sessionId": "customer-4821" }'
```

```json
{
  "reply": "Yes, we ship to Switzerland. Delivery takes 3 to 5 working days.",
  "conversationId": "CONVERSATION_ID",
  "sessionId": "icapi5f0c2b9e4d7a41f8a3c6e1b2d9f07a64",
  "sources": [
    { "title": "Shipping", "url": "https://www.example.com/shipping" }
  ],
  "followUps": ["How much does shipping cost?", "Can I track my order?"],
  "buttons": [],
  "products": [],
  "form": null,
  "leadForm": null,
  "booking": null,
  "liveChat": null
}
```

| Field | Content |
| --- | --- |
| `reply` | The agent's reply as plain text with Markdown, without the widget's control blocks. Empty while someone from your team has the chat (see `liveChat`). |
| `conversationId` | The conversation, as in [Get a conversation](#get-a-conversation). |
| `sessionId` | The conversation's session in intoCHAT. It always starts with `icapi`. |
| `sources` | The pages the reply drew on, as `title` and `url`, when **Show sources under replies** is on. Pages from [web search](/en/docs/web-search) are always listed, with `"web": true`. |
| `followUps` | Suggested next questions, when **Suggest follow-up questions** is on. |
| `buttons` | [Custom buttons](/en/docs/custom-buttons) the agent chose, as `label` and `url`. |
| `products` | Product cards from your [store](/en/docs/connect-your-store) or API actions, with `name`, `url` and, when known, `image`, `price`, `compareAtPrice`, `currency` and `available`. |
| `form` | A [chat form](/en/docs/chat-forms) the agent offered, with its `formId`, `name`, `fields` and `submitLabel`, or `null`. The API can't submit it; show it in your own interface or ignore it. |
| `leadForm` | The [lead form](/en/docs/lead-collection), with its `message`, contact `fields` and `form`, when the agent asked for contact details, or `null`. Contact details written in a message are saved as a lead when **Save contact details written in the chat** is on, as in the widget. |
| `booking` | Open meeting times, with `provider`, `eventTitle`, `eventLength` in minutes, `timeZone` and `days`, each with its `date` and `slots` as UTC times, or `null`. The API can't book a time. |
| `liveChat` | `null`, or the state of [live chat](/en/docs/live-chat): `offered` (the agent may ask your team to join), `requested` (it asked them) or `paused` (a team member has the chat, so the agent didn't reply). |

### How API chats differ from the widget

- The conversation is saved like a widget chat and appears on the agent's **Conversations** tab, with its leads, ratings and statistics. It has no country and no page.
- [Allowed domains](/en/docs/allowed-domains), [blocked countries](/en/docs/limits-and-access#blocked-countries) and [messages per visitor](/en/docs/limits-and-access#messages-per-visitor) don't apply: there is no visitor's page or address to check. The plan's messages, the agent's monthly cap and the key's rate limit do.
- A [private agent](/en/docs/allowed-domains#private-agents) refuses API chats with `agent_private`.
- [Client actions](/en/docs/client-actions) run in a visitor's browser, so the agent isn't offered them in API chats. API actions, buttons, forms and booking work.
- Files can't be attached, and there are no temporary chats.
- Only conversations started through the API can be continued through the API. The agent sees the last 40 messages of the conversation as its history.
- While someone from your team has taken over the chat in the [Live chat inbox](/en/docs/live-chat#the-live-chat-inbox), your message is saved, `reply` is empty and `liveChat` is `paused`. The team member's replies appear in the conversation: read them with [Get a conversation](#get-a-conversation), where they have an `authorName`. A message during a takeover uses none of your plan's messages.
- A reply that takes longer than 55 seconds ends with a `504` error and the code `timeout`. It may still count as a message.

### Streaming

With `"stream": true`, the response is the reply's text as it is written, as `text/plain`, without the JSON fields. The `X-Conversation-Id` and `X-Session-Id` headers carry the conversation and session, and `X-Live-Chat` the live chat state when there is one. Sources, buttons and the other blocks are not streamed; read them afterwards with [Get a conversation](#get-a-conversation). Errors are JSON, as without streaming.

```bash
curl -N https://www.intochat.ai/api/v1/agents/AGENT_ID/chat \
  -H "Authorization: Bearer ic_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "message": "What are your opening hours?", "stream": true }'
```

## List conversations

`GET /api/v1/agents/{agentId}/conversations` · scope `conversations:read`

The agent's conversations, from the widget and the API, newest first, without their messages. Takes `limit`, `cursor` and `since`; see [Pagination](#pagination).

```bash
curl "https://www.intochat.ai/api/v1/agents/AGENT_ID/conversations?limit=50&since=2026-10-01T00:00:00Z" \
  -H "Authorization: Bearer ic_live_YOUR_KEY"
```

```json
{
  "data": [
    {
      "id": "CONVERSATION_ID",
      "sessionId": "8f14e45f-ceea-467a-9575-1a2b3c4d5e6f",
      "channel": "widget",
      "createdAt": "2026-10-04T09:00:00.000Z",
      "lastActivityAt": "2026-10-04T09:12:00.000Z",
      "country": "CH",
      "page": "https://www.example.com/pricing",
      "messageCount": 6,
      "topics": ["Pricing"],
      "sentiment": "positive",
      "liveChat": null,
      "csatScore": null
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

`channel` is `api` for a conversation started through the API, otherwise `widget` (the script tag, the inline iframe, the direct link and the Playground). `topics` and `sentiment` are set by [topic analysis](/en/docs/conversations-and-dashboard#topics-and-sentiment) when it's on. `liveChat` is `null`, `requested`, `active` or `ended`. `csatScore` is the visitor's 1-to-5 rating after a live chat, or `null`.

## Get a conversation

`GET /api/v1/conversations/{conversationId}` · scope `conversations:read`

One conversation of any agent in the account, with every message in order.

```json
{
  "data": {
    "id": "CONVERSATION_ID",
    "agentId": "AGENT_ID",
    "sessionId": "icapi5f0c2b9e4d7a41f8a3c6e1b2d9f07a64",
    "channel": "api",
    "createdAt": "2026-10-04T09:00:00.000Z",
    "lastActivityAt": "2026-10-04T09:14:00.000Z",
    "country": null,
    "page": null,
    "topics": [],
    "sentiment": null,
    "handoffRequestedAt": null,
    "liveChat": { "status": "ended", "startedAt": "2026-10-04T09:05:00.000Z", "endedAt": "2026-10-04T09:13:00.000Z" },
    "csat": { "score": 5, "comment": "Quick and friendly.", "ratedAt": "2026-10-04T09:14:00.000Z" },
    "messages": [
      { "id": "MESSAGE_ID", "role": "assistant", "content": "Hello! How can I help you today?", "createdAt": "2026-10-04T09:00:00.000Z", "authorName": null, "sources": [] },
      { "id": "MESSAGE_ID", "role": "user", "content": "Can I change my delivery address?", "createdAt": "2026-10-04T09:01:00.000Z", "authorName": null, "sources": [] },
      { "id": "MESSAGE_ID", "role": "assistant", "content": "Yes, I'm on it. What's your order number?", "createdAt": "2026-10-04T09:06:00.000Z", "authorName": "Anna", "sources": [] }
    ]
  }
}
```

`role` is `user` for the visitor or your API messages, and `assistant` for the agent and your team. `authorName` is the first name of the team member who wrote a [live chat](/en/docs/live-chat) reply, or `null` when the agent wrote it. `content` is the text without the widget's control blocks; an agent reply's `sources` lists the pages it showed. `handoffRequestedAt` is when the agent emailed the conversation to your team, or `null`. `liveChat` and `csat` are `null` when there was no live chat or no rating.

## List leads

`GET /api/v1/agents/{agentId}/leads` · scope `leads:read`

The agent's leads, newest first. Takes `limit`, `cursor` and `since`; see [Pagination](#pagination).

```json
{
  "data": [
    {
      "id": "LEAD_ID",
      "email": "jane@example.com",
      "phone": null,
      "name": "Jane",
      "customFields": [{ "id": "FIELD_ID", "label": "Company", "value": "Acme" }],
      "source": "form",
      "conversationId": "CONVERSATION_ID",
      "collectedAt": "2026-10-04T09:03:00.000Z",
      "lastSeenAt": "2026-10-04T09:03:00.000Z",
      "submissionCount": 1
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

`source` is `form` for the lead form or `chat` for contact details written in a message. `customFields` uses the labels your form has now. `lastSeenAt` and `submissionCount` change when the same person submits again. `conversationId` is `null` when the conversation was deleted.

## Contacts

An agent's [contacts](/en/docs/contacts): verified visitors, leads, imported contacts and the ones you add here, each with up to 50 custom attributes. Reading them needs `leads:read`; creating, changing and deleting them needs `contacts:write`. Keys created with **Every scope** have both.

A contact looks like this:

```json
{
  "id": "CONTACT_ID",
  "agentId": "AGENT_ID",
  "externalId": "user-4711",
  "email": "jane@example.com",
  "phone": "+41441234567",
  "name": "Jane",
  "attributes": { "plan": "pro", "seats": 5, "vip": true },
  "source": "api",
  "firstSeenAt": "2026-10-04T09:03:00.000Z",
  "lastSeenAt": "2026-10-04T09:03:00.000Z",
  "createdAt": "2026-10-04T09:03:00.000Z",
  "updatedAt": "2026-10-04T09:03:00.000Z"
}
```

`source` is `identity`, `lead`, `import` or `api`: where the contact first came from. `externalId` is the person's user id on your site. Emails are stored in lower case.

### List contacts

`GET /api/v1/agents/{agentId}/contacts` · scope `leads:read`

The agent's contacts, newest first. Takes `limit`, `cursor` and `since` (contacts changed at or after it); see [Pagination](#pagination). Add `email=` or `external_id=` to look up one contact.

```bash
curl "https://www.intochat.ai/api/v1/agents/AGENT_ID/contacts?external_id=user-4711" \
  -H "Authorization: Bearer ic_live_YOUR_KEY"
```

```json
{ "data": [ { "id": "CONTACT_ID", "externalId": "user-4711", "email": "jane@example.com", "attributes": { "plan": "pro" } } ], "hasMore": false, "nextCursor": null }
```

`data` has all the fields shown above; some are left out here.

### Create or update a contact

`POST /api/v1/agents/{agentId}/contacts` · scope `contacts:write`

```bash
curl https://www.intochat.ai/api/v1/agents/AGENT_ID/contacts \
  -H "Authorization: Bearer ic_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "external_id": "user-4711", "email": "jane@example.com", "name": "Jane", "attributes": { "plan": "pro", "seats": 5 } }'
```

| Field | Notes |
| --- | --- |
| `external_id` | Up to 200 characters. Send this or `email`. |
| `email` | A valid email address. |
| `phone` | 7 to 15 digits, optionally starting with `+`. |
| `name` | Up to 100 characters. |
| `attributes` | An object of attribute names and values. Names start with a lower-case letter and use lower-case letters, digits and underscores, up to 40 characters; see [Attributes](/en/docs/contacts#attributes). A value is text up to 1,000 characters, a number or `true`/`false`, and `null` removes the attribute. |

The contact with this `external_id` is updated, otherwise the one with this `email`, otherwise a new contact is created. An email-only contact found this way gets the `external_id`. The fields you send replace what is stored, a field set to `null` is cleared, and fields you leave out stay as they are. Attributes are merged: the ones you send are set, the others are kept.

```json
{ "data": { "id": "CONTACT_ID", "externalId": "user-4711", "email": "jane@example.com", "attributes": { "plan": "pro", "seats": 5 } }, "result": "created" }
```

`result` is `created` (status `201`) or `updated` (status `200`). An email that belongs to a contact with a different external ID, or an external ID or email another contact already has, gets a `409` error with the code `conflict`. A new contact beyond your plan's [contacts per agent](/en/docs/plans-and-limits#contacts) gets a `403` error with the code `contact_limit_reached`.

### Get a contact

`GET /api/v1/contacts/{contactId}` · scope `leads:read`

One contact of any agent in the account, as `{ "data": { … } }`.

### Change a contact

`PATCH /api/v1/contacts/{contactId}` · scope `contacts:write`

Takes the same fields as [Create or update a contact](#create-or-update-a-contact), all optional, and answers with the contact as `{ "data": { … } }`. A contact must keep an external ID, an email or a phone number.

### Delete a contact

`DELETE /api/v1/contacts/{contactId}` · scope `contacts:write`

```json
{ "data": { "id": "CONTACT_ID", "deleted": true } }
```

The contact's leads and conversations are kept.

## List sources

`GET /api/v1/agents/{agentId}/sources` · scope `sources:read`

The agent's knowledge sources in the order of its **Knowledge** tab, with training progress. The text of a source isn't included, except a Q&A pair's question and answer.

```json
{
  "data": [
    {
      "id": "SOURCE_ID",
      "type": "qa",
      "title": "Do you ship to Switzerland?",
      "url": null,
      "filename": null,
      "question": "Do you ship to Switzerland?",
      "answer": "Yes, in 3 to 5 working days.",
      "characters": 71,
      "status": "trained",
      "error": null,
      "trainedAt": "2026-10-02T09:00:00.000Z",
      "createdAt": "2026-10-02T08:59:00.000Z",
      "updatedAt": "2026-10-02T09:00:00.000Z"
    }
  ],
  "training": { "total": 18, "trained": 18, "pending": 0, "processing": 0, "failed": 0, "done": true },
  "characters": { "used": 48210, "limit": 500000 }
}
```

`type` is `website`, `file`, `text`, `qa` or `notion`. `status` is `pending`, `processing`, `trained` or `failed`, with the reason in `error`. `characters.limit` is the training characters per agent on your plan.

## Add a source

`POST /api/v1/agents/{agentId}/sources` · scope `sources:write`

Adds a [text snippet or a Q&A pair](/en/docs/text-and-qa) and starts training, as the **Knowledge** tab does. The agent uses the new source once it is trained; follow its `status` with [List sources](#list-sources).

A text snippet:

```bash
curl https://www.intochat.ai/api/v1/agents/AGENT_ID/sources \
  -H "Authorization: Bearer ic_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "text", "title": "Opening hours", "content": "We are open Monday to Friday, 9:00 to 17:00." }'
```

A Q&A pair:

```json
{ "type": "qa", "question": "Do you ship to Switzerland?", "answer": "Yes, in 3 to 5 working days." }
```

```json
{
  "data": { "id": "SOURCE_ID", "type": "text", "title": "Opening hours", "status": "pending", "characters": 44 },
  "result": "created",
  "training": { "total": 19, "trained": 18, "pending": 1, "processing": 0, "failed": 0, "done": false }
}
```

`data` has all the fields of [List sources](#list-sources); some are left out above. `result` is `created` (status `201`), `updated` when a Q&A pair with the same question, ignoring upper and lower case, already existed and now has the new answer, or `unchanged` when it had exactly this answer (both status `200`). A text snippet is always added as a new source. A title and a question can each be up to 200 characters.

The source counts toward the agent's training characters. A source that would go over your plan's limit gets a `403` error with the code `character_limit_exceeded`, and nothing is added. See [Training characters per agent](/en/docs/plans-and-limits#training-characters-per-agent).

## Delete a source

`DELETE /api/v1/agents/{agentId}/sources/{sourceId}` · scope `sources:write`

Deletes the source and what the agent learned from it, at once, as deleting it on the **Knowledge** tab does. Nothing else needs training again.

```json
{ "id": "SOURCE_ID", "deleted": true, "training": { "total": 17, "trained": 17, "pending": 0, "processing": 0, "failed": 0, "done": true } }
```

## Subscribe a webhook

`POST /api/v1/agents/{agentId}/webhooks` · scope `webhooks:write`

Subscribes a webhook to the agent's events: a REST hook, as Zapier's and Make's instant triggers use it. intoCHAT then sends each event to the address as described in [Webhooks](/en/docs/webhooks#what-is-sent), signed and retried like a webhook added on the **Leads** tab.

| Field | Content |
| --- | --- |
| `url` | Required. The address to send events to. As on the **Leads** tab, it must start with `https://` and point to a public server; private and internal network addresses are refused. |
| `events` | Required. One or more of `lead.created`, `handoff.requested`, `form.submitted`, `booking.created`, `live_chat.requested` and `return.requested`. See [Events](/en/docs/webhooks#events). |
| `source` | Optional. `zapier`, `make` or `api` (the default): what the **Leads** tab shows next to the webhook, for example **via Zapier**. |

```bash
curl https://www.intochat.ai/api/v1/agents/AGENT_ID/webhooks \
  -H "Authorization: Bearer ic_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/intochat", "events": ["lead.created"], "source": "api" }'
```

```json
{
  "id": "WEBHOOK_ID",
  "agentId": "AGENT_ID",
  "url": "https://hooks.example.com/intochat",
  "events": ["lead.created"],
  "source": "api",
  "secret": "whsec_…",
  "createdAt": "2026-10-08T09:00:00.000Z"
}
```

The response has status `201`. Keep `id` to unsubscribe later. `secret` is the webhook's signing secret, returned only here; use it to [verify the signature](/en/docs/webhooks#verify-the-signature) if your receiver can. Tools that can't check signatures, such as Zapier and Make, can ignore it.

A subscribed webhook appears on the agent's **Leads** tab with a **via Zapier**, **via Make** or **via API** badge, where the owner can pause or remove it. It remembers the key that subscribed it: [revoking that key](#revoke-a-key) deletes it. An agent can have up to 25 subscribed webhooks, apart from the 5 added on the **Leads** tab; past that, the request gets a `400` error.

## Unsubscribe a webhook

`DELETE /api/v1/webhooks/{webhookId}` · scope `webhooks:write`

Deletes a webhook that was subscribed through the API, with its delivery history. Any key of the same account can do it. A webhook added on the **Leads** tab, another account's webhook, or one already removed gets `404`.

```json
{ "id": "WEBHOOK_ID", "deleted": true }
```

## List events

`GET /api/v1/agents/{agentId}/events/{event}` · scope `agents:read`, plus `leads:read` or `conversations:read`

The agent's latest events of one kind, newest first, each shaped exactly like the body of a [webhook delivery](/en/docs/webhooks#what-is-sent). Automation tools poll it instead of waiting for a webhook, for example to show real sample data. Takes `limit`, from 1 to 100 (default 20).

| `event` | Also needs | Built from |
| --- | --- | --- |
| `lead.created` | `leads:read` | Leads, newest saved first |
| `form.submitted` | `leads:read` | Form submissions |
| `booking.created` | `leads:read` | Bookings |
| `return.requested` | `leads:read` | Return requests |
| `handoff.requested` | `conversations:read` | Conversations handed off by email |
| `live_chat.requested` | `conversations:read` | Conversations where the agent asked your team to join |
| `conversation.created` | `conversations:read` | New conversations. This event has no webhook; it can only be listed. |

```bash
curl "https://www.intochat.ai/api/v1/agents/AGENT_ID/events/lead.created?limit=5" \
  -H "Authorization: Bearer ic_live_YOUR_KEY"
```

```json
{
  "data": [
    {
      "id": "LEAD_ID",
      "event": "lead.created",
      "created_at": "2026-10-07T09:30:00.000Z",
      "agent": { "id": "AGENT_ID", "name": "Acme Assistant" },
      "data": {
        "leadId": "LEAD_ID",
        "agentId": "AGENT_ID",
        "agentName": "Acme Assistant",
        "email": "jane@example.com",
        "phone": null,
        "name": "Jane",
        "customFields": [],
        "source": "form",
        "conversationId": "CONVERSATION_ID",
        "collectedAt": "2026-10-07T09:30:00.000Z"
      }
    }
  ]
}
```

The differences from a delivery:

- `id` is the record's ID, not a delivery ID, so the same event always has the same `id`: the lead, submission, booking, return request or conversation ID. For hand-offs and live chat requests it is the conversation ID, a colon and the time in milliseconds, because a conversation can ask again later.
- `created_at` is when the record was saved.
- The data is rebuilt from what intoCHAT stores. A hand-off's `summary` isn't stored, so it is `null`, and `test` is `false`. A hand-off that opened a [helpdesk ticket](/en/docs/helpdesk-tickets) carries its `ticket`, as the webhook does. A booking's `providerStatus` is `pending` while it waits for your confirmation, otherwise `accepted`. A return request has its current `status`, `requested` or `handled`.
- `conversation.created` data has `conversationId`, `channel` (`widget` or `api`), `page`, `country` and `createdAt`.

## Get sample events

`GET /api/v1/agents/{agentId}/events/{event}/samples` · scope `agents:read`

Up to 3 of the agent's latest events, as in [List events](#list-events), so a tool always has data to map fields from. When the agent has none yet, or the key doesn't have the scope that event needs (`leads:read` or `conversations:read`), it returns one documented example with the same fields instead, and `sample` is `true`.

```json
{
  "data": [
    {
      "id": "sample_booking.created",
      "event": "booking.created",
      "created_at": "2026-10-07T09:30:00.000Z",
      "agent": { "id": "AGENT_ID", "name": "Acme Assistant" },
      "data": { "bookingId": "sample_booking", "eventTitle": "30 min intro call", "attendeeEmail": "jane@example.com" }
    }
  ],
  "sample": true
}
```

The example's `data` has every field of the event; some are left out above.

## Limits

- The API is included from Starter. On Free, keys can't be created and existing ones get `plan_required`.
- 60 requests a minute per key, and at most 20 active keys per account.
- Each chat reply counts as one message on your plan. An agent answers at most 1,000 messages an hour, widget and API together.
- A chat reply must finish within 55 seconds.
- Sources: text snippets and Q&A pairs only. Website pages, files and Notion pages are added in the dashboard.
- Up to 25 subscribed webhooks per agent, apart from the 5 added on the **Leads** tab.
- Contacts are created and updated one at a time. To add many at once, use [Import a CSV file](/en/docs/contacts#import-a-csv-file) in the dashboard.
- There are no endpoints yet to create or change agents, submit forms, book meetings or take over live chats.

## Next steps

- Get leads and hand-offs pushed to your server as they happen: [Webhooks](/en/docs/webhooks).
- Use intoCHAT in Zapier or Make: [Zapier and Make](/en/docs/zapier-and-make).
- Let your agent call your own API while it answers: [API actions](/en/docs/api-actions).
- Write good text snippets and Q&A pairs: [Text and Q&A](/en/docs/text-and-qa).
- Check what your plan includes: [Plans and limits](/en/docs/plans-and-limits).
