API

Public deals API and CRM webhooks

Updated October 18, 202615 min read

You can run Chato deals from your own software. For example, the website creates a deal from a request, 1C closes it after payment, the warehouse sets a task for a manager. In the other direction, Chato tells your software what happened in the CRM: a deal was moved, won or got a new responsible person.

Set it all up in the cabinet: CRM → ⋯ → CRM settings → API & webhooks. Admins and the owner see this tab. It also links to the reference of all requests in Swagger: https://chato.kz/api/v1/public/docs. The OpenAPI description is at https://chato.kz/api/v1/public/openapi.json.

API key

Click New key, give it a name ("Website", "1C") and tick its permissions:

Permission Allows
deals:read read deals and their notes
deals:write create, change, move, close, delete and restore deals, add notes
contacts:read / contacts:write find and read contacts / create and change contacts
tasks:read / tasks:write read tasks / create and complete tasks
pipelines:read read pipelines, stages, custom fields and loss reasons
users:read read the team list (for responsibleUserId)
messages:send message clients

Click Create key. The key is shown once, so copy it right away. Chato stores only the key's fingerprint and cannot show it again. The key list shows who issued each key and when it was last used. After you click Revoke, the key stops working at once, and your software gets 401.

Treat the key like a password: keep it on the server, in environment variables. Never put it into website code that a visitor's browser can see.

Everything your software does with the key is recorded in the deal history on behalf of whoever issued the key, marked "via API". A key cannot do more than its author: if a pipeline is hidden from that employee, it is hidden from their key too. If the key's author leaves the company, the key stops working (401). Stage automations, notifications and live board updates for colleagues work exactly as they do in the cabinet.

Making requests

https://chato.kz/api/v1/public
Authorization: Bearer chato_sk_...
Content-Type: application/json

You can also pass the key in the X-Api-Key header. A successful response is always in the data field. To check a key, call GET /me: it returns the company, the key id and its permissions.

{ "data": { "workspaceId": "cmulak1n90001295a8kf4ruhe", "keyId": "cmv2sreij000j293db87lukst", "scopes": ["deals:read", "deals:write"] } }
  • Limit: 120 requests per minute per key. Every key has its own counter, so one integration does not eat another's limit. Every response has the X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers (seconds until the counter resets). Above the limit you get 429 with a Retry-After header: how many seconds to wait before retrying. If 30 requests with a wrong key arrive from one IP address within a minute, further requests from that address get 429 until the end of the minute.
  • Retry without duplicates: add an Idempotency-Key header to POST. Any string up to 255 characters works, for example the request id in your system. A repeat with the same key within 24 hours returns the first response with the Idempotent-Replayed: true header and does not create a second deal. While the first request is still running, a repeat gets 409 IDEMPOTENCY_IN_PROGRESS. The same key with a different request body gives 422 IDEMPOTENCY_KEY_REUSED. If the first request ended with an error, the repeat runs again.

Errors

Errors always look the same:

{
  "error": {
    "code": "REQUIRED_FIELDS",
    "message": "Заполните поля, чтобы перенести в «Договор подписан»",
    "textCode": "REQUIRED_FIELDS",
    "params": { "stage": "Договор подписан" },
    "details": {
      "stageId": "dmo_p1_s5",
      "stageName": "Договор подписан",
      "missing": [{ "id": "dmo_f4", "key": "data_dogovora", "label": "Дата договора", "type": "DATE", "options": [] }]
    }
  },
  "statusCode": 422
}

message is always in Russian. To show the error in another language, translate it by code or textCode.

Code When
400 VALIDATION_FAILED invalid data; the list of errors is in details.validation
400 CONTACT_IDENTITY_REQUIRED there is neither a phone nor an e-mail to find or create a contact
400 PHONE_TOO_SHORT the phone has fewer than 10 digits
401 UNAUTHORIZED no key, a wrong key or a revoked key
403 FORBIDDEN_SCOPE the key lacks the required permission (which one is in params.scope)
404 DEAL_NOT_FOUND and similar no such object in your company (other companies' ids always give 404)
404 CRM_DISABLED the CRM is not turned on in the company
409 IDEMPOTENCY_IN_PROGRESS a request with the same Idempotency-Key is still running
422 REQUIRED_FIELDS the stage needs required fields; which ones is in details.missing
422 LOSS_REASON_REQUIRED moving to "Lost" without a reason while the pipeline requires one
422 IDEMPOTENCY_KEY_REUSED the same Idempotency-Key came with a different request body
429 RATE_LIMITED limit exceeded; see Retry-After

Reference data

curl https://chato.kz/api/v1/public/pipelines -H "Authorization: Bearer $CHATO_KEY"

This request returns pipelines with their stages:

{
  "data": [
    {
      "id": "cmulbined000f29fg1na5uhw2",
      "name": "Sales",
      "position": 0,
      "lossReasonRequired": true,
      "stages": [
        { "id": "cmulbinee000g29fg7o2ep8ev", "name": "New request", "color": "#3B82F6", "position": 0, "kind": "OPEN" },
        { "id": "cmulbinee000j29fgem0qk8ti", "name": "Won", "color": "#10B981", "position": 3, "kind": "WON" },
        { "id": "cmulbinee000k29fgkfdfjzdk", "name": "Lost", "color": "#EF4444", "position": 4, "kind": "LOST" }
      ]
    }
  ]
}

kind is the stage type: OPEN — a working stage, WON — "Won", LOST — "Lost". lossReasonRequired: true means "Lost" needs a reason.

Other reference data:

  • GET /deal-fields — custom fields: key (the field code used to write values), label, type, options (choices {id, label}), pipelineIds (which pipelines have the field; an empty list means all of them), requiredStageIds (on which stages the field is required);
  • GET /loss-reasons — loss reasons {id, name};
  • GET /users — team members: id, name, email, role (needs the users:read permission).

Deals

Create

curl -X POST https://chato.kz/api/v1/public/deals \
  -H "Authorization: Bearer $CHATO_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: site-order-10542' \
  -d '{
    "pipelineId": "PIPELINE_ID",
    "title": "Website request",
    "amount": 150000,
    "contact": { "name": "Aigerim", "phone": "+7 701 555 11 22", "email": "aigerim@example.com" },
    "tags": ["website"],
    "fields": { "gorod": "almaty", "obem": 40 }
  }'

Response 201:

{
  "data": {
    "id": "cmv2srw0l000l293dy0wyl3kk",
    "title": "Website request",
    "amount": 150000,
    "currency": "KZT",
    "status": "open",
    "pipeline": { "id": "cmulbined000f29fg1na5uhw2", "name": "Sales" },
    "stage": { "id": "cmulbinee000g29fg7o2ep8ev", "name": "New request", "kind": "OPEN" },
    "contact": { "id": "cmulm6bkt0007299yv6tep4fx", "name": "Aigerim", "phone": "77015551122", "email": "aigerim@example.com", "createdAt": "2026-09-28T19:01:56.381Z" },
    "responsible": { "id": "cmulak1n60000295aiftvjchb", "name": "Askhat", "email": "askhat@example.com" },
    "tags": ["website"],
    "fields": { "gorod": "almaty", "obem": 40 },
    "source": "api",
    "utm": null,
    "description": null,
    "lossReason": null,
    "lossComment": null,
    "conversationId": null,
    "channelId": null,
    "createdBy": { "id": "cmulak1n60000295aiftvjchb", "name": "Askhat", "email": "askhat@example.com" },
    "createdAt": "2026-10-10T19:38:45.333Z",
    "updatedAt": "2026-10-10T19:38:45.333Z",
    "closedAt": null,
    "deletedAt": null,
    "contactCreated": true
  }
}

A deal looks the same in the responses of all requests. status is open, won, lost or unsorted (not yet sorted).

The contact is set with contactId or contact. With contact, Chato first looks the person up: by phone (last 10 digits, any format), then by e-mail (case-insensitive). If nobody is found, it creates a new contact. For a contact that is found, only empty fields are filled in. contactCreated in the response tells you whether a new contact was created.

If you do not pass pipelineId, the deal lands in the first pipeline. If you do not pass stageId, the deal lands in the first stage of the pipeline. Without title, the title is «Сделка: » (in Russian). The default responsible person is the key's author; "responsibleUserId": null means no one. You can also pass description. The deal source is api.

Custom fields go in the fields object by the codes from /deal-fields. For a list, pass the choice id; for multi-select, an array of ids; for a date, a string like 2026-09-28; for a checkbox, true or false. An invalid value returns 400 naming the field. You can pass up to 20 tags, each up to 40 characters.

Change

curl -X PATCH https://chato.kz/api/v1/public/deals/DEAL_ID \
  -H "Authorization: Bearer $CHATO_KEY" -H 'Content-Type: application/json' \
  -d '{"amount": 180000, "tags": ["website", "vip"], "fields": {"oplata": "invoice"}}'

Only what you send changes: title, amount, responsibleUserId, description, tags, fields, source. tags replace the previous list. null in a field clears it. You can also pass stageId (with lossReasonId, lossComment): the deal then moves to that stage by the same rules as /move.

Move, close

# to a stage — same rules as on the board
curl -X POST https://chato.kz/api/v1/public/deals/DEAL_ID/move \
  -H "Authorization: Bearer $CHATO_KEY" -H 'Content-Type: application/json' \
  -d '{"stageId": "STAGE_ID", "fields": {"data_dogovora": "2026-09-28"}}'

# won
curl -X POST https://chato.kz/api/v1/public/deals/DEAL_ID/close \
  -H "Authorization: Bearer $CHATO_KEY" -H 'Content-Type: application/json' \
  -d '{"result": "won"}'

# lost — with a reason from /loss-reasons
curl -X POST https://chato.kz/api/v1/public/deals/DEAL_ID/close \
  -H "Authorization: Bearer $CHATO_KEY" -H 'Content-Type: application/json' \
  -d '{"result": "lost", "lossReasonId": "REASON_ID", "lossComment": "chose someone else"}'

move, close and restore return the deal with code 200. If the stage has required fields that are empty in the deal, you get 422 REQUIRED_FIELDS with the list of fields. Repeat the request with them added to fields. Moving to "Lost" with move also accepts lossReasonId and lossComment. close moves the deal to the first "Won" or "Lost" stage of its pipeline. If you need another stage of that type, pass its stageId.

Find, read, delete

curl 'https://chato.kz/api/v1/public/deals?pipelineId=PIPELINE_ID&status=open&limit=50' \
  -H "Authorization: Bearer $CHATO_KEY"

Response: {"data": {"items": [...], "nextCursor": "..."}}. Newest deals come first. limit is 1 to 100, 50 by default. The next page is the same request with &cursor=<nextCursor>. nextCursor: null means there are no more pages.

Filters:

  • pipelineId, stageId;
  • status: open, won, lost, closed (won and lost), unsorted or all (default; everything except deleted deals);
  • responsibleUserId (or none — no responsible person), contactId, tag;
  • search — by title, client name or number;
  • updatedSince and createdSince — an ISO date. Handy for syncing: "what changed since last time".

Other requests:

  • GET /deals/DEAL_ID — one deal;
  • DELETE /deals/DEAL_ID — to the trash for 30 days (response 204 with no body); POST /deals/DEAL_ID/restore — bring it back from the trash.

Notes and tasks

curl -X POST https://chato.kz/api/v1/public/deals/DEAL_ID/notes \
  -H "Authorization: Bearer $CHATO_KEY" -H 'Content-Type: application/json' \
  -d '{"text": "Payment received in 1C"}'

curl -X POST https://chato.kz/api/v1/public/deals/DEAL_ID/tasks \
  -H "Authorization: Bearer $CHATO_KEY" -H 'Content-Type: application/json' \
  -d '{"kind": "CALL", "text": "Call back", "dueAt": "2026-10-12T09:00:00+05:00"}'

curl -X POST https://chato.kz/api/v1/public/tasks/TASK_ID/complete \
  -H "Authorization: Bearer $CHATO_KEY" -H 'Content-Type: application/json' \
  -d '{"result": "Got through"}'

Response to creating a task:

{
  "data": {
    "id": "cmv2ssf3x000y293dabkcfivc",
    "dealId": "cmv2srw0l000l293dy0wyl3kk",
    "contactId": "cmulm6bkt0007299yv6tep4fx",
    "kind": "CALL",
    "customType": null,
    "text": "Call back",
    "dueAt": "2026-10-12T04:00:00.000Z",
    "allDay": false,
    "responsible": { "id": "cmulak1n60000295aiftvjchb", "name": "Askhat", "email": "askhat@example.com" },
    "createdBy": { "id": "cmulak1n60000295aiftvjchb", "name": "Askhat", "email": "askhat@example.com" },
    "completedAt": null,
    "completedBy": null,
    "result": null,
    "createdAt": "2026-10-10T19:39:10.078Z"
  }
}

Lists: GET /deals/DEAL_ID/notes and GET /deals/DEAL_ID/tasks. A note comes as {id, dealId, text, author, pinned, createdAt}.

Task fields:

  • dueAt — the due date, required;
  • kind — the task type: CALL (call, default), MEETING (meeting), WRITE (write), OTHER or your own type (CUSTOM together with customTypeId);
  • allDay — an all-day task;
  • responsibleUserId — the assignee. By default the task goes to the deal's responsible person.

Contacts

  • GET /contacts?phone=87015551122 or ?email=... — find; up to 20 contacts are returned. Without phone and email you get 400;
  • GET /contacts/CONTACT_ID — one contact;
  • POST /contacts with the body {name, phone, email} — create. If the person already exists, the existing contact is returned (200, "created": false). A new contact is returned with code 201 and "created": true;
  • PATCH /contacts/CONTACT_ID — change the name, phone or e-mail.

A contact: {id, name, phone, email, createdAt}.

Message a client

You need the messages:send permission. The request is POST /messages. In the body, pass conversationId or phone, and for a new number also channelId. Add text and/or fileUrl (optionally with fileName). It works the same as the message action of the inbound link.

{ "data": { "status": "sent", "conversationId": "cmt9x1a2b0003o701abcd1234" } }

If there is no conversation with this number and channelId is not passed, you get 404.

Webhooks: deal events to your URL

Click Add endpoint, enter an https://… URL and tick the events. After saving, the endpoint gets its own signing secret: click "Show" to see it. A company can have up to 10 endpoints.

Event When
deal.created a deal was created (in the cabinet, via API, from an inquiry)
deal.updated the title, amount, tags, description, source, custom fields or responsible person changed
deal.stage_changed the deal moved to another stage
deal.won / deal.lost the deal was won / lost
deal.deleted / deal.restored the deal was moved to the trash / restored from the trash
deal.responsible_changed the responsible person changed
task.created / task.completed a task was created / completed
note.created a note was added

The same endpoint can also receive chat events: message.received, conversation.closed and others (see Webhooks). In chat events, data has the same fields as for the assistant's address.

Chato sends a POST with a body like this (the deal is shortened):

{
  "id": "evt_28b44037b3de48b5b944430d9546c5c1",
  "event": "deal.stage_changed",
  "createdAt": "2026-09-29T04:46:42.196Z",
  "workspaceId": "cmulak1n90001295a8kf4ruhe",
  "data": {
    "deal": {
      "id": "cmulms3cp000r29eujik2dr1e",
      "title": "Website request",
      "amount": 150000,
      "currency": "KZT",
      "status": "open",
      "pipeline": { "id": "...", "name": "Sales" },
      "stage": { "id": "...", "name": "Invoice sent", "kind": "OPEN" },
      "contact": { "id": "...", "name": "Aigerim", "phone": "77015551122", "email": "aigerim@example.com", "createdAt": "2026-09-28T19:01:56.381Z" },
      "responsible": { "id": "...", "name": "Askhat", "email": "askhat@example.com" },
      "tags": ["website"],
      "fields": { "gorod": "almaty" },
      "closedAt": null
    },
    "changes": {
      "stage": {
        "from": { "id": "...", "name": "In progress", "kind": "OPEN" },
        "to": { "id": "...", "name": "Invoice sent", "kind": "OPEN" }
      }
    },
    "actor": { "type": "user", "userId": "cmulak1n60000295aiftvjchb" }
  }
}

What is in data:

  • deal — a full snapshot of the deal, in the same shape the API returns;
  • changes — what changed, as from → to. Keys: title, amount, tags, description, source, responsible, stage, fields.<code>;
  • actor.type — who made the change: user (an employee in the cabinet), api, webhook, automation (then automationId is next to it) or system;
  • reason — for deal.deleted, explains why the deal was deleted;
  • task or note — for task.* and note.created events, sits next to deal.

Request headers:

  • X-Chato-Event — the event;
  • X-Chato-Event-Id — the same on all retries; use it to drop duplicates;
  • X-Chato-Delivery — the delivery id;
  • X-Chato-Timestamp — the send time, in Unix seconds;
  • X-Chato-Signature — the signature;
  • User-Agent: Chato-Webhooks/1.0.

Verifying the signature

X-Chato-Signature is sha256= plus an HMAC-SHA256 of the string <X-Chato-Timestamp>.<raw body>, computed with the endpoint secret (the secret starts with whsec_). Compute the signature over the raw body, before parsing JSON. Reject requests older than 5 minutes.

Node.js (Express):

const crypto = require('node:crypto');
app.post('/chato', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.get('X-Chato-Timestamp');
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.CHATO_WEBHOOK_SECRET)
    .update(`${ts}.${req.body}`)
    .digest('hex');
  const given = req.get('X-Chato-Signature') || '';
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  if (!fresh || given.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(200); // answer at once, heavy work in the background
});

PHP:

$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_CHATO_TIMESTAMP'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, getenv('CHATO_WEBHOOK_SECRET'));
$given = $_SERVER['HTTP_X_CHATO_SIGNATURE'] ?? '';
if (abs(time() - (int) $ts) > 300 || !hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
http_response_code(200);

Python (Flask):

import hmac, hashlib, os, time
from flask import request, abort

@app.post("/chato")
def chato():
    body = request.get_data()  # raw bytes
    ts = request.headers.get("X-Chato-Timestamp", "")
    expected = "sha256=" + hmac.new(
        os.environ["CHATO_WEBHOOK_SECRET"].encode(), f"{ts}.".encode() + body, hashlib.sha256
    ).hexdigest()
    given = request.headers.get("X-Chato-Signature", "")
    if abs(time.time() - int(ts or 0)) > 300 or not hmac.compare_digest(expected, given):
        abort(401)
    event = request.get_json()
    return "", 200

Retries, log, auto-disable

  • A delivery counts as successful if your server answers with any 2xx code within 10 seconds. Otherwise Chato retries after 1 min, 5 min, 30 min, 2 h and 6 h. That is six attempts in total over 8.5 hours. Chato does not follow redirects (3xx) and counts them as failures.
  • Each endpoint has a Delivery log: status, response code, number of attempts, what was sent and what your server answered. Send again repeats the delivery with the same event id. The log is kept for 30 days.
  • An endpoint turns itself off if 25 attempts in a row failed and the failures have lasted more than a day. The owner and admins get a notification. Fix the receiver and click Turn on.
  • Test sends a test ping event with data: {"message": "Проверка адреса вебхука Chato"}.
  • New secret issues a new signing secret. The old one stops matching at once.

Answer 200 right away and do the processing in the background. Chato counts a slow answer as a failure and retries. The retry arrives with the same X-Chato-Event-Id, so it is easy to recognise.

If you already use the assistant's inbound link, it can handle deals too. The rules are the same as in the API, and the deal history shows a "via webhook" mark. Actions run on behalf of the company owner.

{ "action": "deal.create", "contact": { "name": "Aigerim", "phone": "+77015551122" }, "deal": { "title": "Order from 1C", "amount": 77000 } }
{ "action": "deal.move", "dealId": "DEAL_ID", "stageId": "STAGE_ID" }
{ "action": "deal.note", "dealId": "DEAL_ID", "text": "Payment received" }

The response contains status (deal_created, deal_moved or note_added) and deal, and for deal.note also note.