Public deals API and CRM webhooks
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-RemainingandX-RateLimit-Resetheaders (seconds until the counter resets). Above the limit you get429with aRetry-Afterheader: 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 get429until the end of the minute. - Retry without duplicates: add an
Idempotency-Keyheader toPOST. 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 theIdempotent-Replayed: trueheader and does not create a second deal. While the first request is still running, a repeat gets409 IDEMPOTENCY_IN_PROGRESS. The same key with a different request body gives422 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 theusers:readpermission).
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 «Сделка: "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),unsortedorall(default; everything except deleted deals);responsibleUserId(ornone— no responsible person),contactId,tag;search— by title, client name or number;updatedSinceandcreatedSince— 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 (response204with 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),OTHERor your own type (CUSTOMtogether withcustomTypeId);allDay— an all-day task;responsibleUserId— the assignee. By default the task goes to the deal's responsible person.
Contacts
GET /contacts?phone=87015551122or?email=...— find; up to 20 contacts are returned. Withoutphoneandemailyou get400;GET /contacts/CONTACT_ID— one contact;POST /contactswith 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 code201and"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, asfrom→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(thenautomationIdis next to it) orsystem;reason— fordeal.deleted, explains why the deal was deleted;taskornote— fortask.*andnote.createdevents, sits next todeal.
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
2xxcode 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
pingevent withdata: {"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.
Deals through the inbound link
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.



