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, and blocking a contact (block/unblock).
List: filters, sorting, pagination
The list supports pagination (page; perPage — 5 to 100, default 10), free-text search (search), a statuses[] filter (only active and inactive are accepted — the lead status cannot be filtered on, and passing it returns 422) and sorting (sortBy: id|display_name|company|status|created_at, default created_at; sortDir: asc|desc, default desc).
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, related collections (phones, emailes, contactAccounts) are persisted as a whole: PATCH fully rebuilds the linked records from the request body.
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).
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.
Endpoints
| Method | Path | Summary |
|---|---|---|
| GET | /v1/contactsList the company's contacts (query: search, statuses[]=active|inactive, sortBy, sortDir, exclude_lid_only, page, perPage). | List the company's contacts (query: search, statuses[]=active|inactive, sortBy, sortDir, exclude_lid_only, page, perPage). |
| 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). | Update a contact (fully rewrites related phones/emails/accounts). |
| 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}/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 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. |
| 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": "+77001234567", "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": "+77001234567", "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=%2B77001234567" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": [
{
"id": 45,
"displayName": "John Doe",
"status": "lead",
"phones": [{ "phone": "+77001234567", "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": "+77001234567", "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": "+77001234567", "type": "mobile" }],
"emailes": [{ "email": "john@example.com", "type": "work" }],
"contactAccounts": [
{
"id": 88,
"channel_type": "whatsapp",
"external_id": "77001234567",
"username": null,
"profile_picture": 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." }
]
}
}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": []
}
}