Public API and webhooks
The REST API lets your CRM, website backend or own bot work with cenaly.ge leads and conversations the same way the admin panel does: read cards, change properties, send events, reply in conversations. Webhooks work the other way round — they notify your server about new leads, events and messages.
What you need#
| What | Where |
|---|---|
| Role | account owner — the key acts on their behalf; staff do not see keys |
| Section | chat enabled (leads and conversations live there) |
| Key | Integrations → API & webhooks → Keys → Create key |
| API address | https://api.cenaly.ge/public-api/v1 |
| Method reference | api.cenaly.ge/public-api/v1/docs |
| OpenAPI 3.1 | api.cenaly.ge/public-api/v1/openapi.json |
Step 1. Key and permissions#
- Open Integrations and click API & webhooks in the page header.
- On the Keys tab click Create key and enter a name (“CRM”, “Website backend”, “Telegram bot”).
- Pick a permission set or tick permissions one by one; with several locations — which locations the key may use; and the expiry.
- Copy the
cnk_…secret. It is shown only once: we keep only its fingerprint (SHA-256).
| Permission | What it allows |
|---|---|
contacts:read |
list, search and card of a lead, properties |
contacts:write |
change properties and tags, merge, erase on a data subject request |
events:read |
the event feed of a lead |
events:write |
send events (purchase, sign-up, anything of your own) |
conversations:read |
conversations and messages |
conversations:write |
operator reply, note, status, assignee, tags |
messages:send |
a message to the lead’s latest conversation |
segments:read |
saved segments and their members |
Sets: CRM / backend (leads, properties, events, reading conversations and segments), Own bot (reads leads, replies in conversations, writes to leads), Read only, Everything.
- Permissions change the key’s permissions and locations on the fly — the integration keeps working.
- Reissue gives a new secret; the old one stops working immediately, permissions and the log stay.
- Revoke — immediately and for good, the next request gets
401.
Pass the key in the Authorization: Bearer cnk_… header (or X-Api-Key: cnk_…). Keep it on your server only — never in a browser or a mobile app.
Step 2. First requests#
KEY=cnk_your_key
API=https://api.cenaly.ge/public-api/v1
# what the key can do: permissions, locations, limit
curl -H "Authorization: Bearer $KEY" "$API/me"
# leads, newest first (20 per page)
curl -H "Authorization: Bearer $KEY" "$API/contacts?limit=20"
# search by name, email or phone
curl -H "Authorization: Bearer $KEY" "$API/contacts?q=anna"
The response is always an envelope:
{
"meta": { "status": 200, "requestId": "…", "nextCursor": "WyIyMDI2LTEwLTA0…" },
"data": { "total": 128, "contacts": [ { "id": "…", "status": "lead", "name": "Anna", "updatedAt": "2026-10-04T09:12:00Z" } ] }
}
The next page is the same request with cursor=<meta.nextCursor>; no nextCursor — no more pages. An error is {"meta": {...}, "error": {"code": "…"}}.
Location. If the key has one location, you do not need to pass it. With several — add location=<location id> (the list is GET /v1/locations), otherwise you get 400 location_required.
Properties and events by your user_id. You do not have to store our lead id: add ?by=user_id, and your user identifier becomes the address. No lead with that user_id yet — we create one.
# properties: operations update_or_create, set_once, add, delete, append, union, exclude
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"ops":[{"op":"update_or_create","key":"$email","value":"anna@example.com"},
{"op":"add","key":"orders","value":1},
{"op":"union","key":"interests","value":"yoga"}]}' \
"$API/contacts/u-1001/props?by=user_id"
# events in a batch (up to 100); a repeat with the same id counts once
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"events":[{"id":"ord-5531","userId":"u-1001","name":"order_paid","props":{"sum":1450,"currency":"RUB"}}]}' \
"$API/events"
# reply in a conversation and change its status
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"text":"Hello! Your order is on its way."}' "$API/conversations/<id>/reply"
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"status":"resolved"}' "$API/conversations/<id>/status"
Writes go through the same queue as edits from the panel: changes apply within seconds, and a write by user_id answers 202 with pending. In the lead card the author of the change is shown as “API:
Methods#
| Method | What it does | Permission |
|---|---|---|
GET /v1/me, GET /v1/locations |
key, permissions, locations | — |
GET /v1/contacts |
leads: status (anon, lead, user), q, limit, cursor |
contacts:read |
GET /v1/contacts/{id} |
card: properties, tags, event summary | contacts:read |
POST /v1/contacts/{id}/props |
properties via operations (up to 250) or {"props":{…}} |
contacts:write |
POST / DELETE /v1/contacts/{id}/tags |
lead tags | contacts:write |
POST /v1/contacts/merge |
merge two cards: preview, then "apply": true |
contacts:write |
POST /v1/contacts/{id}/erase |
erase on a data subject request ("confirm": true) |
contacts:write |
GET /v1/contacts/{id}/events |
the lead’s event feed | events:read |
POST /v1/contacts/{id}/events, POST /v1/events |
events (up to 100 at once) | events:write |
GET /v1/contacts/{id}/conversations |
the lead’s conversations | conversations:read |
POST /v1/contacts/{id}/messages |
write to the lead’s latest conversation | messages:send |
GET /v1/conversations, /{id}, /{id}/messages |
conversations and messages | conversations:read |
POST /v1/conversations/{id}/reply |
operator reply or a note ("type": "note") |
conversations:write |
POST /v1/conversations/{id}/status, /assign, /tags |
status, assignee, tags | conversations:write |
GET /v1/segments, GET /v1/segments/{id}/contacts |
segments and their members | segments:read |
Fields, examples and error codes of every method are in the reference.
Step 3. Webhooks#
Webhooks are set up per location: Chat → Settings → Webhooks — an https address, events and a signing secret. Lead events were added next to the conversation events (new conversation, new message, status, rating, tags):
| Event | When it arrives |
|---|---|
lead.created |
a new lead appeared (contact left in the chat, a form, or created via the API) |
lead.props_changed |
lead properties changed — the keys are in changedProps |
lead.event_tracked |
the lead has an event; by default all your events without system ones ($…), the list of names is set in “Which events to send” |
message.goal_reached |
the lead reached a message goal (pop-up, campaign) |
{
"event": "lead.event_tracked",
"deliveryId": "WD…",
"at": "2026-10-04T09:12:03Z",
"webhookId": "…",
"locationId": "…",
"contact": { "id": "…", "status": "user", "userId": "u-1001", "props": { "orders": 3 }, "tags": ["vip"] },
"leadEvent": { "name": "order_paid", "at": "2026-10-04T09:12:00Z", "props": { "sum": 1450 } }
}
The lead’s name, email and phone go into the body only if the webhook has “Send guest contacts” ticked. Verify the signature with HMAC-SHA256 of the body using the webhook secret:
# header X-Meni-Signature: sha256=<hex>
echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET"
Answer 2xx within 5 seconds — otherwise the delivery is retried; after a long run of failures the webhook is switched off, and you see it in the settings. Drop duplicates by deliveryId.
Check the call log#
If an integration stops receiving data, open Integrations → API & webhooks → Call log and select the key and period. The last 14 days are available; choose 1, 3 or 7 days for a shorter check.
Entries show the time and key, HTTP method, path without identifiers, response code, an error if present, and duration in milliseconds. Hover over the request result to see its IP address. Request and response bodies are not logged, so record contents and personal data are not duplicated in the technical history. The log lets you check a call's result, but cannot reconstruct the full exchange.
If a record is missing, check the selected key and expand the period to 14 days. Older calls are not displayed in this window.
Send sales to an external BI system#
A manager with locations in Moscow and Kazan wants sales in their own analytics system. A public API key does not expose sales: its permissions cover only leads, lead events, conversations, messages and segments. Even the Everything preset adds no order or receipt methods.
To send new order events, configure a scenario:
- Open Settings → Status automation → Scenarios (Flow) and click Add scenario.
- Under When, choose Order is paid. Other choices are Order is placed, Order status changes and Order is cancelled.
- Under Then, add Call webhook and enter your integration handler's address. Add conditions if needed.
- Wait for Saved, place a matching test order and check that your handler receives the event.
The result is a call to your handler when a matching event occurs. Your integrator sets up loading into BI and field mapping; a scenario does not replace an export of all past history. If the event does not arrive, check that the scenario is enabled, the event and all conditions match, and the handler's address is correct. The public API log covers incoming calls made with keys; check delivery of this outgoing webhook on the receiving side.
For a one-off analysis, use Export CSV in the order list or prepare a data archive. These are manual options, not a promise of scheduled BI refreshes.
Limitations#
- 60 requests per minute per key; above that —
429with aRetry-Afterheader. What is left — inX-RateLimit-*. - Up to 200 records per page (50 by default), up to 100 events per batch, up to 250 property operations per request, up to 30 tags at once.
- Up to 20 live keys per account.
- Call log displays the last 14 days. Request and response bodies are absent; method, path without identifiers, result, error, duration and IP help investigate failures without copying record contents.
- Events sent via the API land in the lead card (feed, summary, segments), but not in the raw visit analytics log.
- You can write to a lead only in an existing conversation: no conversation —
409 no_conversation.
Troubleshooting#
| Response | What it means | What to do |
|---|---|---|
401 api_key_missing / api_key_invalid |
no key in the request, or it is wrong, revoked or expired | check the header; reissue the key if needed |
403 scope_required |
the key lacks the permission | Permissions of the key — add it |
400 location_required |
the key has several locations | add location=<id> |
404 location_not_found |
the location is not among the key’s locations | check GET /v1/locations |
404 not_found |
no such lead or conversation | for your own ids use ?by=user_id |
429 rate_limited |
more than 60 requests per minute | wait Retry-After seconds, batch events |
502 upstream_error |
temporary failure | retry later |
Every request is visible on the Call log tab — with the response code and the key name.
FAQ#
Can I call the API from a browser? No: the key gives access to all leads of a location. Call the API from your server.
How is user_id different from a lead id? A lead id is our card identifier. user_id is your user identifier (a CRM number, a website account id); with ?by=user_id you can address the card by it.
What happens if I send an event twice? An event with the same id counts once — feel free to retry the request after a network error.
Do I need a separate key per location? No: one key can work with several locations, the location is chosen with the location parameter.
Related sections#
- Guest chat — conversations, leads and webhook settings
- Leads — the unified inbox
- API and MCP of your PBX — the separate telephony API
- Integrations