Contacts
A contact is the record for a person or organisation you communicate with: phone numbers, emails, linked channel accounts (contactAccounts — e.g. a specific WhatsApp number or Instagram username), tags, and a status (lead/active/inactive). A single contact can have multiple linked accounts and conversations. The API covers CRUD, lookup/resolution by identifier, duplicate merging, broadcast opt-in/opt-out management, blocking a contact (block/unblock), and hiding one from the shared working inbox (hide-from-inbox/unhide-from-inbox).
List: filters, sorting, pagination
The list supports pagination (page; perPage — 5 to 100, default 10), free-text search (search), a statuses[] filter (lead, active, inactive, blocked; either an array or a comma-separated string) and sorting (sortBy: id|display_name|company|status|created_at|last_activity_at; sortDir: asc|desc, default desc).
last_activity_at, not created_at — the most recently active contacts first, contacts with no activity last, with id descending as the tiebreaker. With sortDir=asc the contacts with no activity come first instead (a deliberate index trade-off, not a bug). Clients that need the previous ordering must pass sortBy=created_at explicitly.Contact-panel filters (since 2026-09-08, combined with AND): channel_type — a channel-type key (the value from GET /contacts/channel-options, not a display name): keeps contacts that have a linked account of that type; whatsapp_business is canonicalised to whatsapp, because a Cloud API number and a "grey" WhatsApp share the same contact account. tag_id — contacts carrying that tag. responsible_user_id — contacts owned by that responsible manager. active_since (a YYYY-MM-DD date) — only contacts whose last_activity_at is on or after that date; contacts with no activity at all are excluded.
The created_since parameter (a date) keeps only contacts created at or after the given moment; unassigned=1 keeps only contacts without a responsible user (mutually exclusive with responsible_user_id). GET /contacts/counters returns the counters for the list tabs: {"data": {"all", "mine", "new_week", "blocked", "hidden"}} — all (non-hidden), mine (non-hidden with you as the responsible user), new_week (created in the last 7 days), blocked (blocked, non-hidden), hidden (hidden from the inbox). Search and panel chips do not affect the counters. Permission: contact.view; an embed token gets 403; limit 60 requests per minute per user; values are cached for 60 seconds.
The hidden filter (0/1) relates to hiding from the working inbox (see below): hidden=1 returns ONLY hidden contacts, while omitting it (or 0) returns only the non-hidden ones. There is no "show both" value.
Each list row additionally carries last_activity_at (the contact's last activity — a message or a call, in any of its conversations), open_deals_count (the number of open deals) and responsible_user ({id, name} or null). The latter two are emitted only by the Contacts group endpoints (index/show/store/update/merge/hide/unhide) — the contact nested inside conversation and thread resources does not carry them, and a missing key must be read as "not requested", not as zero or null.
The exclude_lid_only flag (0/1, default 1) hides, by default, contacts whose only linked accounts are private WhatsApp @lid identifiers with no resolved number; pass 0 to include them.
Quick lookup
The /lookup shortcut searches phone numbers and linked accounts (external_id, username) and returns up to 20 matches.
Access and photos
A contact from another company returns 404 "Contact not found." on read, update, delete and on nested sub-resources. The photo endpoints stream the image, falling back to the linked account's profile_picture.
Creating and updating linked records
On create and update, phones and emailes are persisted as a whole: PATCH fully rebuilds those collections from the request body.
For contactAccounts the rules changed on 2026-09-08 (a plain save of the contact card used to be able to delete the contact's live WhatsApp/Instagram account along with the threads bound to it). A list item may carry the id of an existing account — the same id the response returns — and is then matched by it; otherwise by the (account_type, external_id) pair; otherwise, when no external_id is sent, by the contact's single account of that type. An account with a non-empty external_id is never deleted by this endpoint, and its external_id cannot be reassigned through it either: sending a different value is silently ignored (moving a provider identity is a contact merge). Only accounts without an external_id that are absent from the incoming list get deleted. An empty contactAccounts array deletes nothing, and omitting the key from the request body leaves the accounts untouched. The account login is accepted both as login and as username.
status accepts lead, active and inactive on create and update (lead is a new contact's default; before 2026-09-08 it could not be passed explicitly — the endpoint answered 422). responsibleUserId in PATCH is validated within the current company: a user id from another company returns 422 rather than being assigned silently.
Since 2026-09-23 photo and contactAccounts.*.profilePicture are no longer a free string: the value must be either an absolute http(s) URL or a path inside the current company's storage directory — anything else (another company's path, an arbitrary string) is rejected with 422. The form re-submits the signed photo link this same API returned (GET/POST/PATCH) — sending it back unchanged is read as "the photo was not touched": photo is then simply not re-saved (the field is optional, the stored photo stays), and an unchanged profilePicture inside contactAccounts is turned into null (the controller keeps the existing account's stored picture when it receives null).
Calling a contact
A pair of telephony endpoints (available only when telephony is enabled) answers "can I call this contact right now": GET /outbound-eligibility returns, for the given channel_id, a two-block DTO — message (the state of WhatsApp's 24-hour messaging window) and call (whether a call is allowed and whether permission is needed/can be requested) — while POST /call-permission/request sends the contact a call-permission request (WhatsApp Calling).
For SIP channels calling is always allowed (permission_status=not_required) and no permission is requested. Meta's permission-request limits: at most once per 24 hours and no more than twice per rolling 7 days — when exhausted the POST returns 429 with an eligibility field whose request_cooldown_until (Unix time when the limit lifts) is set.
Blocking a contact
POST /contacts/{contact}/block and /unblock (since 2026-09-04) perform a real block: blocked_at on the contact is set first and stays the source of truth even if every provider call below is rejected outright. The service then best-effort blocks the contact on every WhatsApp channel of the company (whatsapp and whatsapp_business) that has at least one thread with the contact — a channel the contact never messaged is left untouched. The outcome for each such channel lands in the response's provider_failures[] ({channel_id, error_code, message}); an empty array means full success (or that there were no provider channels at all), a non-empty one does not undo the local block — blocked_at in the same response is already set. Calling block again on an already-blocked contact is a no-op (message: "Contact was already blocked.", provider_failures: []), with no provider calls made; likewise for unblock on an already-unblocked contact.
GET /contacts/{contact}/block-preview answers "what would blocking do" BEFORE the block itself: channels[] lists the channels a provider-side block would reach (channel_id, channel_name, channel_type, provider_block_supported), each with a window_open flag. window_open is only meaningful for whatsapp_business: false means the contact has not written in the last 24 hours and the Cloud API will refuse the block right now (the same reason that would otherwise come back as not_reachable_24h in provider_failures[]); grey channels have no such rule and always report true. An empty channels[] means the block would be local only. Same permission (contact:update), rate-limited under throttle:api-token-heavy.
Both endpoints require the contact:update permission (like opt-out/opt-in) and share a dedicated throttle:contact-block limit: 6 changes per hour per contact, 60 per hour and 300 per day per company. To read the list of numbers the provider itself has blocked on a specific channel (not what AISAR has blocked), use GET /channels/{channel}/whatsapp/blocked-users in the "Channels" group — its authorization is different (channel:view). POST /contacts/{contact}/block, POST /contacts/{contact}/unblock and GET /contacts/{contact}/block-preview are available to business companies only: for a family (kids) company all three answer 403.
Contact activity and CSAT
GET /contacts/{contact}/activity returns a merged feed of the contact's latest events from four sources: calls (type: "call"), deal stage changes plus won/lost (deal_stage), CSAT survey responses (csat) and broadcast participation (broadcast). Each source is capped by limit separately first, then everything is merged, sorted by occurred_at (descending) and capped again — so one noisy source cannot starve the others. limit is an integer from 1 to 50, default 10. The response is {"data": {"items": [...]}}, with no pagination. An item is {type, occurred_at, title, meta, link}, where title is a short data label (the number for a call, the deal/broadcast name, the sentiment for CSAT), not a ready-made sentence; meta differs per type (call: direction, status, duration_sec, recording_status, has_transcript; deal: deal_id, activity_type, to_stage_id, to_stage_name; CSAT: sentiment; broadcast: broadcast_id, status); link is either null or {kind, id} with kind one of call_transcript|deal|conversation|broadcast. Permission: contact.view; rate limit 60 requests/minute per user (throttle:contact-activity-index).
Calls in this feed follow the same visibility rule as the calls journal: a rank-and-file operator only sees calls it was a party to or was assigned, and only a holder of company_telephony.manage sees the company-wide picture. contact.view alone is not enough for someone else's calls. Internal fields are never exposed: a call's metadata and a broadcast recipient's variables / error_message never appear in meta.
GET /contacts/{contact}/csat returns the contact's satisfaction summary: {"data": {"positive_count", "negative_count", "last": {"sentiment", "responded_at"} | null}}. There is no numeric score at all — the system stores only a two-value sentiment (positive/negative), so the response has none either. Only surveys the contact actually RESPONDED to are counted (a sent, expired or skipped survey is not yet a satisfaction signal). The client's free-text reply (raw_response) is deliberately never included. Permission: contact.view; rate limit 120 requests/minute per user (throttle:contact-csat-show). Both endpoints answer 404 "Contact not found." for a contact from another company.
Contact files and media
GET /contacts/{contact}/attachments (since 2026-09-08) returns a paginated gallery of the contact's files across ALL of its conversations at once — unlike GET /conversations/{conversation}/attachments, which is scoped to a single conversation. Permission: conversation.view (this is message content, not the contact card); an active subscription is required; rate limit 60 requests/minute per user (throttle:contact-attachments-index). A contact from another company answers 404 "Contact not found.". The same accessible-channel filter as the conversation list applies on top: attachments from a dialog on a channel the operator cannot open never reach the gallery. Hiding a contact from the inbox (hidden_from_inbox_at) barely affects this endpoint — a hidden contact is still readable by id and its "Files" tab does not go empty; the one exception, since 2026-09-29, is that such a contact's call recordings are left out of the gallery (see below).
Parameters: kind (optional, media | document; without it both categories are returned), page, perPage (default 24, max 60). The response is {"data": {"items": [...], "pagination": {"page", "perPage", "total", "lastPage"}, "counts": {"media", "document"}}}. counts are computed over the whole gallery and do NOT depend on kind (the "Media N" / "Documents M" chips must not change when the operator switches tabs), while pagination.total does respect the active kind. Ordering is by the message's send time (the provider timestamp, falling back to the row's creation time) descending, ties broken by attachment id — so an album of several photos keeps its order.
Classification: media — image, video, ig_story, ig_reel, ig_post, story_mention; document — document, file, contact (a vCard) and audio (an audio FILE is downloaded or played from a list, not scanned in a grid). Everything else never enters the gallery and is not counted in counts: voice, sticker, template, ephemeral, unsupported_type, share, ig_story_reply, location. Voice notes are excluded deliberately — a voice note is an utterance in the dialog, not a file. The same classification is available on the conversation gallery, but there it is spelled family: on GET /conversations/{conversation}/attachments the name kind is already taken by the raw attachment types.
A gallery row is the attachment's own fields (id, type, url, thumbnail_url, playback_url, filename, mime_type, size, width, height, duration; metadata is deliberately NOT returned in a gallery) plus kind, family, extension, source, message_id, conversation_id, channel_id, channel_type, direction, sent_at, sender. family is one of pdf|word|excel|powerpoint|archive|audio|contact|certificate|text|image|video|other (derived from the attachment type, the filename extension and the MIME type); extension is the uppercase extension stamp (PDF, DOCX) or null when neither the name nor the MIME yields one; source is only set for Instagram media (story|reel|post|story_mention) and is null otherwise; direction is inbound|outbound|system; sent_at is the provider send time of the message; sender is {type: contact|user|system, name}. For a vCard (type: "contact") the row also carries contact_name and phone — a short summary of the card; the full vCard body is never exposed.
The url, thumbnail_url and playback_url links in this gallery are short-lived signed links valid for 20 minutes (the same policy as the conversation gallery), unlike the permanent signature returned by GET /messages/{message}/attachments. Do not persist them: a gallery page older than 20 minutes starts answering 403 on those links — the correct client behaviour is to re-request the page and get fresh ones.
Hiding a contact from the working inbox
POST /contacts/{contact}/hide-from-inbox and /unhide-from-inbox (since 2026-09-08) take a contact out of the company's shared working inbox and put it back. This is neither a block nor a channel-level chat exclusion: everything the contact sends is still received and stored — only the surfaces that put it in front of the team are closed. Same permission as blocking (contact:update), the same throttle:contact-block limit and the same refusal for an embed token (403). Both endpoints are idempotent: calling them again on an already-hidden (or already-visible) contact answers 200 and emits no event. The response is the contact card: {"data": {...}}, carrying hidden_from_inbox_at and hidden_from_inbox_by_user_id.
While a contact is hidden its conversations stay out of GET /conversations, GET /conversations/counters (every counter) and GET /threads, are not found by the message search GET /messages/search, do not appear in the calls journal (for telephony supervisors either), are excluded from broadcast audiences, do not fire automations, auto-replies or the AI agent, produce no automatic outbound messages (the CSAT survey records a skip), send no "new message" push and no missed-call alert, and are not mirrored into Bitrix24/amoCRM.
What does not change: the conversation and its messages are still readable by id (GET /conversations/{id}, GET /messages), a human operator can still reply manually, outgoing webhooks (message.created included) keep being delivered to your integrations, and conversation exports still include hidden contacts. Hidden contacts can only be found through GET /contacts?hidden=1 — the conversation list has no "show hidden" switch. No new event type was introduced: both hiding and unhiding emit contact.updated. Merging is fail-closed: if either of the two contacts was hidden, the surviving contact stays hidden. The exception is call content (since 2026-09-29): while the contact is hidden, a call recording neither plays nor shows up in attachment galleries, GET /calls/{call}/recording-url and GET /calls/{call}/transcript answer 404, and the AI call summary is withheld from the message feed, the notes list and exports. Nothing is deleted — unhiding the contact brings it all back; webhooks already delivered are not recalled.
Excluding a contact from auto-leads
Since 2026-09-29 PATCH /contacts/{contact} takes auto_lead_excluded (boolean) — "do not create leads for this contact": true sets the exclusion (sending true again keeps the original moment and author), false lifts it, including one set when a lead was declined (POST /deals/{deal}/decline with exclude_contact: true). The permission is the same as for every other field, contact.update. The exclusion affects automatic leads only: the contact's messages and calls arrive as usual, and a deal can still be created manually, by an automation or by an AI agent.
The contact card reports the exclusion through auto_lead_excluded_at (an ISO timestamp or null — not null ⇔ no auto-leads for the contact), auto_lead_excluded_via (manual — from the contact card, decline — when a lead was declined, or null) and auto_lead_excluded_by_user_id — present in every contact object. The name of whoever set it, auto_lead_excluded_by (a string or null), comes only in the GET /contacts/{contact} and PATCH /contacts/{contact} responses; lists and nested contacts do not carry the key.
Contact and user photo
GET /contacts/{contact}/photo and GET /users/{user}/photo (since 2026-08-24) always return a webp preview up to 256px wide, when the source is a decodable image wider than 256px; otherwise (already ≤256px, or GD couldn't decode the format) the original is streamed as before. There is currently no way to explicitly request the original from either route — a dedicated param would need to be added if one is ever needed. Both routes are signed and share the throttle:signed-media rate limit (2000 requests/minute per IP) with GET .../attachments/{attachment}/media.
Since 2026-09-23 the contact card's photo field is a signed link shaped /v1/contacts/{contact}/photo/{version}, where version is a 16-character hex string derived from the stored file's path: a new photo means a new path, hence a new version and a new link — so the browser can cache it as immutable with no separate invalidation. The unversioned link (GET /contacts/{contact}/photo) still works — it remains the base route and keeps serving links already handed out. Neither link's signature expires.
CSV contact import
An import runs in several steps: upload the file (POST /contact-imports) → optionally re-read it with another encoding/delimiter (/reparse) and get a column-mapping suggestion from the AI (/ai-mapping) → a dry run without writing (/validate, asynchronous) → start (/start) → the final report (/report). Every step returns the same import object as data.import. Supported files are .csv/.txt up to 10 MB, up to 50,000 rows and 200 columns; the encoding is auto|utf-8|windows-1251 and the delimiter auto, ;, ,, tab or | (both are auto-detected by default; the values actually used are in encoding/delimiter, the detected ones in detected_encoding/detected_delimiter). GET /contact-imports/template?locale=ru|en|kk returns a CSV template (UTF-8 with BOM, ; delimiter).
contact.created webhook, does not trigger automations ("new contact" → greeting) and does not sync contacts to Bitrix24/amoCRM: the file often comes from that very CRM, and mass-greeting everyone in a file would get the channel banned. Learn the outcome from the contact_import.updated / completed / failed realtime events (channel private-user.{userId}, author only) or by polling GET /contact-imports/{import}.Permissions: contact.import together with contact.create for every endpoint of the group; the fill_empty and overwrite modes additionally require contact.update (without it 422 on options.duplicate_mode with the code duplicate_mode_forbidden), and creating a new tag requires tag.create (without it the tag is not created and the stats carry the tag_not_created warning). An embed token gets 403 on the whole group, reads included. An import is visible to its author; roles with a company-wide view see every import of the company; someone else's or a missing import is 404. Records and files are kept for 7 days (expires_at) and only those are listed.
Limits: uploads and /reparse share 10 per hour per user (429); at most 20 uploads per day per company (422, daily_limit; deleting an import does not give the slot back); /ai-mapping — 10 per hour per user and 60 per hour per company (when exhausted the response is 200 with ai_status: "unavailable"); /start — 10 per minute; /validate, the CSV downloads and delete — 30 per minute; list reads — 60 and single reads — 120 per minute. Only one import per company can be validating or running at a time: otherwise 409 import_in_progress.
Statuses (status): uploaded → validating → validated → importing → completed; also failed and cancelled. The mapping and options can change only in uploaded/validated (otherwise 409 import_not_editable). mapping is an array of {column_index, field, transform?, date_format?, source?}; field is first_name, last_name, full_name, phone, email, company, title, dob, tags, responsible, comments or null (skip the column); several columns are allowed only for phone, email, tags, comments; transform is split_full_name, normalize_phone, match_user, date_format (valid only for its own field); at least a name or a phone is required. options: duplicate_mode — skip | fill_empty (the default: fill only the empty fields of an existing contact) | overwrite; tag_name (up to 50 characters; the tag every imported contact receives); default_responsible_user_id (a company member); create_whatsapp_accounts (default true). Duplicates are matched by phone within the company; at most 10 phones and 10 e-mails per contact are accepted, the extra ones are dropped with a warning.
The import object: id, status, original_filename, file_size, total_rows, encoding, delimiter, detected_encoding, detected_delimiter, headers, column_samples, suggested_mapping (a hint from the header dictionary), mapping, mapping_hash, options, stats (after validation: new, update, skip, file_duplicates, errors, error_breakdown, warning_breakdown), preview (the first 5 rows to be created/updated), the counters processed_rows, created_count, updated_count, skipped_count, failed_count, tag ({id, name} or null), error_code (why it failed), has_errors_file, has_report, ai_mapping_used, ai_mapping_available, is_mine (whether the current user uploaded it), and the timestamps created_at, validated_at, started_at, finished_at, expires_at. POST /validate and POST /start answer 202 and continue in the background. mapping_hash pins the mapping and options checked by validate; POST /start must send it in the body — otherwise 409 stale_validation (validate again).
Business-rule refusals arrive as {"data": {"message", "code", "errors": {field: [code]}}}. 422: not_csv, empty_file, no_header, too_many_rows, too_many_columns, cell_too_long, invalid_encoding, daily_limit, mapping_invalid_column, mapping_duplicate_column, mapping_duplicate_field, mapping_invalid_transform, mapping_name_conflict, mapping_requires_phone_or_name, duplicate_mode_forbidden. 409: import_not_editable, import_in_progress (also when deleting an import that is currently running), stale_validation. 410 file_missing — the source file is already gone. 404 with code: "feature_disabled" — the AI mapping suggestion is switched off (see ai_mapping_available). GET /errors (a CSV of the rows with errors, available after validation) and GET /report (a CSV with the per-row outcome, after completion) stream the file; if there is none, 404.
Every item of emailes in the response carries id (the address id), email and type. That id is passed as contact_email_id to POST /conversations/{conversation}/threads to write the contact a first letter through an email channel. The create and update body does not need id: the address set is rebuilt from email and type, so an address id can change after a PATCH. Take a fresh id from an up-to-date response.
Endpoints
| Method | Path | Summary |
|---|---|---|
| GET | /v1/contactsList the company's contacts (query: search, statuses[]=lead|active|inactive|blocked, channel_type, tag_id, responsible_user_id, unassigned, active_since, created_since, hidden, sortBy, sortDir, exclude_lid_only, page, perPage; sorted by last_activity_at desc by default). | List the company's contacts (query: search, statuses[]=lead|active|inactive|blocked, channel_type, tag_id, responsible_user_id, unassigned, active_since, created_since, hidden, sortBy, sortDir, exclude_lid_only, page, perPage; sorted by last_activity_at desc by default). |
| GET | /v1/contacts/countersCounters for the contact list tabs: all, mine, new_week, blocked, hidden (search and chips are not applied). | Counters for the contact list tabs: all, mine, new_week, blocked, hidden (search and chips are not applied). |
| GET | /v1/contacts/lookupQuick contact lookup by phone, external_id or channel username. | Quick contact lookup by phone, external_id or channel username. |
| GET | /v1/contacts/channel-optionsList channel types available for linking an account to a contact. | List channel types available for linking an account to a contact. |
| GET | /v1/contacts/{contact}Fetch a single contact (phones, emailes, contactAccounts, tags). | Fetch a single contact (phones, emailes, contactAccounts, tags). |
| POST | /v1/contactsCreate a contact (with phones, emails and linked channel accounts). | Create a contact (with phones, emails and linked channel accounts). |
| POST | /v1/contacts/resolve-identifiersResolve a batch of identifiers (phone/username/external_id) to existing contact ids. | Resolve a batch of identifiers (phone/username/external_id) to existing contact ids. |
| PATCH | /v1/contacts/{contact}Update a contact (fully rewrites related phones/emails/accounts; auto_lead_excluded — exclude the contact from auto-leads or bring it back). | Update a contact (fully rewrites related phones/emails/accounts; auto_lead_excluded — exclude the contact from auto-leads or bring it back). |
| DELETE | /v1/contacts/{contact}Delete a contact (soft delete). | Delete a contact (soft delete). |
| POST | /v1/contacts/{contact}/mergeMerge a contact into another one (all conversations and accounts move to the target). | Merge a contact into another one (all conversations and accounts move to the target). |
| POST | /v1/contacts/{contact}/tagsSync a contact's tags (full replace of the tag set). | Sync a contact's tags (full replace of the tag set). |
| POST | /v1/contacts/{contact}/opt-outOpt a contact out of broadcasts. | Opt a contact out of broadcasts. |
| POST | /v1/contacts/{contact}/opt-inOpt a contact back into broadcasts (clears opt-out). | Opt a contact back into broadcasts (clears opt-out). |
| GET | /v1/contacts/{contact}/block-previewWhat a block would reach before it is applied: the channels it would go to, and whether WhatsApp's 24-hour window is open on each. | What a block would reach before it is applied: the channels it would go to, and whether WhatsApp's 24-hour window is open on each. |
| POST | /v1/contacts/{contact}/blockBlock a contact (body: reason?) — sets blocked_at and best-effort blocks it on every WhatsApp channel it has messaged. | Block a contact (body: reason?) — sets blocked_at and best-effort blocks it on every WhatsApp channel it has messaged. |
| POST | /v1/contacts/{contact}/unblockUnblock a contact — clears blocked_at and best-effort unblocks it on the affected WhatsApp channels. | Unblock a contact — clears blocked_at and best-effort unblocks it on the affected WhatsApp channels. |
| GET | /v1/contacts/{contact}/activityMerged activity feed for the contact: calls, deal stage changes, CSAT responses and broadcasts (query: limit, 1–50, default 10). | Merged activity feed for the contact: calls, deal stage changes, CSAT responses and broadcasts (query: limit, 1–50, default 10). |
| GET | /v1/contacts/{contact}/csatThe contact's CSAT summary: positive_count, negative_count and the latest response (sentiment, responded_at). | The contact's CSAT summary: positive_count, negative_count and the latest response (sentiment, responded_at). |
| GET | /v1/contacts/{contact}/attachmentsPaginated gallery of the contact's files across all of its conversations (query: kind=media|document, page, perPage — default 24, max 60) with `counts` for both categories; the media links live 20 minutes. | Paginated gallery of the contact's files across all of its conversations (query: kind=media|document, page, perPage — default 24, max 60) with `counts` for both categories; the media links live 20 minutes. |
| POST | /v1/contacts/{contact}/hide-from-inboxHide a contact from the shared working inbox — its dialogs leave the lists, counters, search, automations and broadcast audiences, while its messages keep arriving. | Hide a contact from the shared working inbox — its dialogs leave the lists, counters, search, automations and broadcast audiences, while its messages keep arriving. |
| POST | /v1/contacts/{contact}/unhide-from-inboxReturn a hidden contact to the shared working inbox (clears hidden_from_inbox_at). | Return a hidden contact to the shared working inbox (clears hidden_from_inbox_at). |
| GET | /v1/contacts/{contact}/dealsList a contact's deals (requires the deals feature to be enabled). | List a contact's deals (requires the deals feature to be enabled). |
| GET | /v1/contacts/{contact}/outbound-eligibilityCheck outbound-call eligibility for the contact on a channel (query: channel_id) — messaging window and call permission. | Check outbound-call eligibility for the contact on a channel (query: channel_id) — messaging window and call permission. |
| POST | /v1/contacts/{contact}/call-permission/requestRequest call permission from the contact (WhatsApp Calling; body: channel_id). | Request call permission from the contact (WhatsApp Calling; body: channel_id). |
| GET | /v1/contacts/{contact}/photoThe contact's photo (falls back to the linked account's profile_picture) — a webp preview up to 256px when the source is decodable and wider; otherwise the original, streamed. The unversioned link, kept working for links already handed out. | The contact's photo (falls back to the linked account's profile_picture) — a webp preview up to 256px when the source is decodable and wider; otherwise the original, streamed. The unversioned link, kept working for links already handed out. |
| GET | /v1/contacts/{contact}/photo/{version}The contact's photo with the path version in the link (since 2026-09-23) — same delivery as the unversioned route, but the link can be cached by the browser as immutable. | The contact's photo with the path version in the link (since 2026-09-23) — same delivery as the unversioned route, but the link can be cached by the browser as immutable. |
| GET | /v1/contact-imports/templateThe CSV template for importing contacts (query: locale = ru|en|kk, default ru). | The CSV template for importing contacts (query: locale = ru|en|kk, default ru). |
| GET | /v1/contact-importsList contact imports of the last 7 days (query: page, perPage 1–20); the author sees their own, company-wide roles see all. | List contact imports of the last 7 days (query: page, perPage 1–20); the author sees their own, company-wide roles see all. |
| POST | /v1/contact-importsUpload a CSV (multipart: file up to 10 MB, encoding?, delimiter?) — 201 with the import object and a column-mapping hint. | Upload a CSV (multipart: file up to 10 MB, encoding?, delimiter?) — 201 with the import object and a column-mapping hint. |
| GET | /v1/contact-imports/{import}The import: status, mapping, validation stats, preview and counters. | The import: status, mapping, validation stats, preview and counters. |
| POST | /v1/contact-imports/{import}/reparseRe-read the file with another encoding/delimiter (body: encoding?, delimiter?); resets the mapping and validation. | Re-read the file with another encoding/delimiter (body: encoding?, delimiter?); resets the mapping and validation. |
| POST | /v1/contact-imports/{import}/ai-mappingAI column-mapping suggestion: ai_status (ok|unavailable), mapping[] and summary {mapped, total, to_check}. | AI column-mapping suggestion: ai_status (ok|unavailable), mapping[] and summary {mapped, total, to_check}. |
| POST | /v1/contact-imports/{import}/validateStore the mapping and options and queue a dry run without writing — 202. | Store the mapping and options and queue a dry run without writing — 202. |
| GET | /v1/contact-imports/{import}/errorsA CSV of the rows that failed validation (after validate). | A CSV of the rows that failed validation (after validate). |
| POST | /v1/contact-imports/{import}/startStart importing a validated file (body: mapping_hash) — 202; contacts are created with no contact.created webhook, automations or CRM sync. | Start importing a validated file (body: mapping_hash) — 202; contacts are created with no contact.created webhook, automations or CRM sync. |
| GET | /v1/contact-imports/{import}/reportThe CSV report of a finished import: the outcome of every row. | The CSV report of a finished import: the outcome of every row. |
| DELETE | /v1/contact-imports/{import}Delete the import record and its files (409 while validating or importing). | Delete the import record and its files (409 while validating or importing). |
| GET | /v1/users/{user}/photoA company user's photo — a webp preview up to 256px when the source is decodable and wider; otherwise the original, streamed. | A company user's photo — a webp preview up to 256px when the source is decodable and wider; otherwise the original, streamed. |
Examples
Create a contact
Request
curl -X POST "https://api.aisar.app/v1/contacts" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"firstName": "John",
"lastName": "Doe",
"phones": [{ "phone": "+12025550187", "type": "mobile" }],
"emailes": [{ "email": "john@example.com", "type": "work" }]
}'Response
{
"data": {
"id": 45,
"company_id": 1,
"firstName": "John",
"lastName": "Doe",
"displayName": "John Doe",
"status": "lead",
"phones": [{ "phone": "+12025550187", "type": "mobile" }],
"emailes": [{ "email": "john@example.com", "type": "work" }],
"contactAccounts": [],
"merged_into_contact_id": null,
"blocked_at": null,
"blocked_reason": null,
"blocked_by_user_id": null
}
}Look up a contact by phone
Request
curl -X GET "https://api.aisar.app/v1/contacts/lookup?q=%2B12025550187" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": [
{
"id": 45,
"displayName": "John Doe",
"status": "lead",
"phones": [{ "phone": "+12025550187", "type": "mobile" }]
}
]
}List contacts filtered by status
Request
curl -X GET "https://api.aisar.app/v1/contacts?perPage=10&statuses=active,inactive&sortBy=created_at&sortDir=desc" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"items": [
{
"id": 45,
"displayName": "John Doe",
"status": "active",
"phones": [{ "phone": "+12025550187", "type": "mobile" }],
"tags": []
}
],
"pagination": { "page": 1, "perPage": 10, "total": 120, "lastPage": 12 }
}
}Fetch a single contact
Request
curl -X GET "https://api.aisar.app/v1/contacts/45" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"id": 45,
"company_id": 10,
"firstName": "John",
"lastName": "Doe",
"displayName": "John Doe",
"status": "lead",
"phones": [{ "phone": "+12025550187", "type": "mobile" }],
"emailes": [{ "email": "john@example.com", "type": "work" }],
"contactAccounts": [
{
"id": 88,
"contact_id": 45,
"account_type": "whatsapp",
"external_id": "+12025550187",
"username": null,
"name": "John",
"profileDisplayName": "John",
"addressBookName": null,
"profilePicture": null,
"is_group": false,
"status": "active",
"presence": null
}
],
"tags": [{ "id": 3, "name": "VIP" }],
"merged_into_contact_id": null,
"blocked_at": null,
"blocked_reason": null,
"blocked_by_user_id": null
}
}Sync a contact's tags
Request
curl -X POST "https://api.aisar.app/v1/contacts/45/tags" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tag_ids": [1, 2, 3]
}'Response
{
"data": {
"tags": [
{ "id": 1, "name": "Lead" },
{ "id": 2, "name": "Newsletter" },
{ "id": 3, "name": "VIP" }
]
}
}List a contact's deals
Request
curl -X GET "https://api.aisar.app/v1/contacts/45/deals?perPage=20" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"items": [
{
"id": 7,
"title": "Website enquiry",
"status": "open",
"amount": 150000,
"funnel_id": 2,
"stage_id": 5
}
],
"pagination": { "page": 1, "perPage": 20, "total": 3, "lastPage": 1 }
}
}Outbound-call eligibility (WhatsApp)
Request
curl -X GET "https://api.aisar.app/v1/contacts/45/outbound-eligibility?channel_id=7" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"eligibility": {
"message": {
"window_open": true,
"window_expires_at": 1710500000,
"requires_template": false
},
"call": {
"channel_kind": "whatsapp",
"can_call": false,
"permission_status": "none",
"permission_expires_at": null,
"can_request_permission": true,
"request_cooldown_until": null,
"attempts_remaining": 5,
"has_whatsapp_account": true
}
}
}
}Request call permission
Request
curl -X POST "https://api.aisar.app/v1/contacts/45/call-permission/request" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "channel_id": 7 }'Response
{
"data": {
"eligibility": {
"message": {
"window_open": true,
"window_expires_at": 1710500000,
"requires_template": false
},
"call": {
"channel_kind": "whatsapp",
"can_call": false,
"permission_status": "pending",
"permission_expires_at": null,
"can_request_permission": false,
"request_cooldown_until": 1710586400,
"attempts_remaining": 5,
"has_whatsapp_account": true
}
}
}
}Call-permission request limit reached (429)
Request
curl -X POST "https://api.aisar.app/v1/contacts/45/call-permission/request" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "channel_id": 7 }'Response
{
"data": {
"message": "Call-permission request limit reached.",
"eligibility": {
"message": {
"window_open": true,
"window_expires_at": 1710500000,
"requires_template": false
},
"call": {
"channel_kind": "whatsapp",
"can_call": false,
"permission_status": "pending",
"permission_expires_at": null,
"can_request_permission": false,
"request_cooldown_until": 1710586400,
"attempts_remaining": 5,
"has_whatsapp_account": true
}
}
}
}Block a contact
Request
curl -X POST "https://api.aisar.app/v1/contacts/45/block" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "reason": "Оскорбления в переписке" }'Response
{
"data": {
"message": "Contact blocked.",
"contact_id": 45,
"blocked_at": "2026-09-04T10:15:00Z",
"blocked_reason": "Оскорбления в переписке",
"provider_failures": []
}
}Block a contact — the provider rejected one channel
Request
curl -X POST "https://api.aisar.app/v1/contacts/45/block" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'Response
{
"data": {
"message": "Contact blocked.",
"contact_id": 45,
"blocked_at": "2026-09-04T10:15:00Z",
"blocked_reason": null,
"provider_failures": [
{ "channel_id": 9, "error_code": "channel_offline", "message": "The channel is not connected right now — connect it and try again." }
]
}
}Contact list: panel filters
Request
curl -X GET "https://api.aisar.app/v1/contacts?channel_type=whatsapp_business&tag_id=3&responsible_user_id=12&active_since=2026-08-01&perPage=10" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"items": [
{
"id": 45,
"displayName": "John Doe",
"status": "active",
"responsibleUserId": 12,
"responsible_user": { "id": 12, "name": "Aigerim K." },
"open_deals_count": 2,
"last_activity_at": "2026-09-07T18:42:11.000000Z",
"hidden_from_inbox_at": null,
"hidden_from_inbox_by_user_id": null,
"phones": [{ "phone": "+12025550187", "type": "mobile" }],
"tags": [{ "id": 3, "name": "VIP", "color": "#22c55e" }]
}
],
"pagination": { "page": 1, "perPage": 10, "total": 4, "lastPage": 1 }
}
}Contact activity feed
Request
curl -X GET "https://api.aisar.app/v1/contacts/45/activity?limit=5" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"items": [
{
"type": "call",
"occurred_at": "2026-09-07T18:42:11.000000Z",
"title": "+12025550187",
"meta": {
"direction": "outbound",
"status": "completed",
"duration_sec": 96,
"recording_status": "ready",
"has_transcript": true
},
"link": { "kind": "call_transcript", "id": 3120 }
},
{
"type": "deal_stage",
"occurred_at": "2026-09-06T11:05:00.000000Z",
"title": "Website enquiry",
"meta": {
"deal_id": 7,
"activity_type": "stage_changed",
"to_stage_id": 5,
"to_stage_name": "Договор"
},
"link": { "kind": "deal", "id": 7 }
},
{
"type": "csat",
"occurred_at": "2026-09-05T09:20:00.000000Z",
"title": "positive",
"meta": { "sentiment": "positive" },
"link": { "kind": "conversation", "id": 15 }
},
{
"type": "broadcast",
"occurred_at": "2026-09-01T08:00:00.000000Z",
"title": "Сентябрьская акция",
"meta": { "broadcast_id": 12, "status": "delivered" },
"link": { "kind": "broadcast", "id": 12 }
}
]
}
}Contact CSAT summary
Request
curl -X GET "https://api.aisar.app/v1/contacts/45/csat" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"positive_count": 3,
"negative_count": 1,
"last": {
"sentiment": "positive",
"responded_at": "2026-09-05T09:20:00.000000Z"
}
}
}Contact files and media
Request
curl -X GET "https://api.aisar.app/v1/contacts/45/attachments?kind=document&perPage=2" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"items": [
{
"id": 8801,
"type": "document",
"url": "https://api.aisar.app/v1/messages/1001/attachments/8801/media?company=3&expires=1757352000&signature=PLACEHOLDER",
"thumbnail_url": null,
"playback_url": null,
"filename": "contract-2026.pdf",
"mime_type": "application/pdf",
"size": 481233,
"width": null,
"height": null,
"duration": null,
"kind": "document",
"family": "pdf",
"extension": "PDF",
"source": null,
"message_id": 1001,
"conversation_id": 15,
"channel_id": 7,
"channel_type": "whatsapp",
"direction": "inbound",
"sent_at": "2026-09-07T18:42:11.000000Z",
"sender": { "type": "contact", "name": "Айгуль Сатпаева" }
},
{
"id": 8794,
"type": "contact",
"url": null,
"thumbnail_url": null,
"playback_url": null,
"filename": null,
"mime_type": "text/vcard",
"size": null,
"width": null,
"height": null,
"duration": null,
"kind": "document",
"family": "contact",
"extension": "VCF",
"source": null,
"message_id": 994,
"conversation_id": 15,
"channel_id": 7,
"channel_type": "whatsapp",
"direction": "inbound",
"sent_at": "2026-09-06T10:11:02.000000Z",
"sender": { "type": "contact", "name": "Айгуль Сатпаева" },
"contact_name": "Дамир Ахметов",
"phone": "+12025550187"
}
],
"pagination": { "page": 1, "perPage": 2, "total": 5, "lastPage": 3 },
"counts": { "media": 14, "document": 5 }
}
}Hide a contact from the working inbox
Request
curl -X POST "https://api.aisar.app/v1/contacts/45/hide-from-inbox" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"id": 45,
"company_id": 10,
"displayName": "John Doe",
"status": "active",
"last_activity_at": "2026-09-07T18:42:11.000000Z",
"hidden_from_inbox_at": "2026-09-08T09:30:00.000000Z",
"hidden_from_inbox_by_user_id": 12,
"open_deals_count": 2,
"responsible_user": { "id": 12, "name": "Aigerim K." },
"blocked_at": null,
"blocked_reason": null,
"blocked_by_user_id": null,
"phones": [{ "phone": "+12025550187", "type": "mobile" }],
"emailes": [],
"contactAccounts": [],
"tags": []
}
}Return a hidden contact to the inbox
Request
curl -X POST "https://api.aisar.app/v1/contacts/45/unhide-from-inbox" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"id": 45,
"company_id": 10,
"displayName": "John Doe",
"status": "active",
"last_activity_at": "2026-09-07T18:42:11.000000Z",
"hidden_from_inbox_at": null,
"hidden_from_inbox_by_user_id": null,
"open_deals_count": 2,
"responsible_user": { "id": 12, "name": "Aigerim K." },
"blocked_at": null,
"blocked_reason": null,
"blocked_by_user_id": null,
"phones": [{ "phone": "+12025550187", "type": "mobile" }],
"emailes": [],
"contactAccounts": [],
"tags": []
}
}Unblock a contact
Request
curl -X POST "https://api.aisar.app/v1/contacts/45/unblock" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"message": "Contact unblocked.",
"contact_id": 45,
"blocked_at": null,
"blocked_reason": null,
"provider_failures": []
}
}CSV contact import: upload a file
Request
curl -X POST "https://api.aisar.app/v1/contact-imports" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-F "file=@contacts.csv"Response
{
"data": {
"import": {
"id": 31,
"status": "uploaded",
"original_filename": "contacts.csv",
"file_size": 20480,
"total_rows": 240,
"encoding": "utf-8",
"delimiter": ";",
"detected_encoding": "utf-8",
"detected_delimiter": ";",
"headers": ["ФИО", "Телефон", "Email"],
"column_samples": [["Иванова Анна"], ["+12025550187"], ["anna@example.com"]],
"suggested_mapping": [
{ "column_index": 0, "field": "full_name", "transform": "split_full_name" },
{ "column_index": 1, "field": "phone", "transform": "normalize_phone" },
{ "column_index": 2, "field": "email" }
],
"mapping": null,
"mapping_hash": null,
"options": null,
"stats": null,
"preview": null,
"processed_rows": 0,
"created_count": 0,
"updated_count": 0,
"skipped_count": 0,
"failed_count": 0,
"tag": null,
"error_code": null,
"has_errors_file": false,
"has_report": false,
"ai_mapping_used": false,
"ai_mapping_available": true,
"is_mine": true,
"created_at": "2026-10-08T09:00:00.000000Z",
"validated_at": null,
"started_at": null,
"finished_at": null,
"expires_at": "2026-10-15T09:00:00.000000Z"
}
}
}CSV contact import: validate and start
Request
curl -X POST "https://api.aisar.app/v1/contact-imports/31/validate" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"mapping": [
{ "column_index": 0, "field": "full_name", "transform": "split_full_name" },
{ "column_index": 1, "field": "phone", "transform": "normalize_phone" },
{ "column_index": 2, "field": "email" }
],
"options": { "duplicate_mode": "fill_empty", "tag_name": "Импорт октябрь" }
}'
# после события contact_import.updated со status "validated" (или опроса GET /v1/contact-imports/31):
curl -X POST "https://api.aisar.app/v1/contact-imports/31/start" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "mapping_hash": "<mapping_hash из объекта импорта, 64 символа>" }'Response
{
"data": {
"import": {
"id": 31,
"status": "importing",
"total_rows": 240,
"stats": {
"new": 212,
"update": 20,
"skip": 3,
"file_duplicates": 2,
"errors": 3,
"error_breakdown": { "no_name_no_phone": 3 },
"warning_breakdown": { "invalid_email": 4 }
},
"processed_rows": 0,
"created_count": 0,
"updated_count": 0,
"skipped_count": 0,
"failed_count": 0,
"tag": { "id": 77, "name": "Импорт октябрь" },
"has_errors_file": true,
"has_report": false
}
}
}CSV contact import: stale validation (409)
Request
curl -X POST "https://api.aisar.app/v1/contact-imports/31/start" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "mapping_hash": "0000000000000000000000000000000000000000000000000000000000000000" }'Response
{
"data": {
"message": "stale_validation",
"code": "stale_validation",
"errors": { "mapping_hash": ["stale_validation"] }
}
}