# Contacts

> Manage contacts and contact information

Source: https://docs.soosh.io/reference/api/contacts/

## Overview

Contacts represent WhatsApp users you communicate with. Each contact stores their phone number, profile information, and conversation history.

Contacts belong to the workspace, not to a WhatsApp number: there is one contact per phone number, shared by every number the workspace connects. `whatsapp_account` only records the number the contact last talked to, which replies go from by default.

> **Note:** **Role-based access**: Agents can only see and interact with contacts assigned to them. Admins and Managers can see all contacts.

## List Contacts

Retrieve a paginated list of contacts.

`GET /api/contacts`

### Query Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `page` | integer | Page number (default: 1) |
| `limit` | integer | Page size, 1–100. Default `50`. |
| `search` | string | Search by profile name (case-insensitive) or phone number. Truncated at 1000 characters. |
| `tags` | string | Comma-separated tags; matches contacts having ANY of them |

Contacts are ordered by `last_message_at DESC` (nulls last), then `created_at DESC`.

### Response

```json
{
  "status": "success",
  "data": {
    "contacts": [
      {
        "id": "uuid",
        "phone_number": "1234567890",
        "name": "John",
        "profile_name": "John",
        "avatar_url": "https://...",
        "status": "active",
        "tags": ["vip", "billing"],
        "metadata": { "custom_field": "value" },
        "last_message_at": "2024-01-01T12:00:00Z",
        "last_message_preview": "See you tomorrow!",
        "unread_count": 2,
        "assigned_user_id": "uuid",
        "whatsapp_account": "Main Business",
        "last_inbound_at": "2024-01-01T11:00:00Z",
        "service_window_open": true,
        "marketing_opt_out": false,
        "created_at": "2024-01-01T00:00:00Z",
        "updated_at": "2024-01-01T12:00:00Z"
      }
    ],
    "total": 100,
    "page": 1,
    "limit": 50
  }
}
```

### Response Fields

| Field | Type | Description |
|-------|------|-------------|
| `whatsapp_account` | string | The **name** (not a UUID) of the WhatsApp number the contact last talked to; empty if they haven't yet. Messages go from it unless you pick another. |
| `assigned_user_id` | uuid | The agent the contact is assigned to, if any |
| `unread_count` | integer | Number of unread incoming messages |
| `last_message_preview` | string | Preview text of the most recent message |
| `service_window_open` | boolean | True if the customer messaged within the last 24 hours (`last_inbound_at`) |
| `marketing_opt_out` | boolean | Whether the contact has opted out of marketing messages |
| `country` | string | ISO country of the number (`IN`), for showing a flag. Left out when phone numbers are masked |
| `status` | string | Always the literal `"active"` — contacts have no status column. |
| `name` | string | Mirrors `profile_name`; both hold the same value. |

> **Note:** There is no `account_id` or `assigned_to` field — the account is referenced by name via `whatsapp_account`, and the assigned agent is `assigned_user_id`.

> **Careful:** When the organization enables **Mask phone numbers** (Settings → General), `phone_number` is
> returned masked, and `profile_name` / `name` are masked too if they look like a phone number.

## Get Contact

Retrieve a single contact by ID.

`GET /api/contacts/{id}`

### Response

```json
{
  "status": "success",
  "data": {
    "id": "uuid",
    "phone_number": "1234567890",
    "name": "John",
    "profile_name": "John",
    "avatar_url": "https://...",
    "status": "active",
    "tags": ["vip"],
    "assigned_user_id": "uuid",
    "whatsapp_account": "Main Business",
    "metadata": {
      "custom_field": "value"
    },
    "unread_count": 0,
    "last_message_at": "2024-01-01T12:00:00Z",
    "last_message_preview": "Thanks!",
    "service_window_open": true,
    "marketing_opt_out": false,
    "created_at": "2024-01-01T00:00:00Z",
    "updated_at": "2024-01-01T12:00:00Z"
  }
}
```

## Create Contact

Create a new contact (or restore a soft-deleted one with the same phone number). Requires `contacts:write`.

`POST /api/contacts`

### Request Body

```json
{
  "phone_number": "+1234567890",
  "profile_name": "John Doe",
  "whatsapp_account": "Main Business",
  "tags": ["vip"],
  "metadata": {
    "custom_field": "value"
  }
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `phone_number` | string | Yes | Phone number with the country code. `+91 98765-43210` and `0091 9876543210` are both stored as `919876543210`; an existing contact with the same number (however it was written) is returned as a conflict |
| `profile_name` | string | No | Display name for the contact |
| `whatsapp_account` | string | No | **Name** of the WhatsApp number to send from by default. Usually left out: it's set when the contact writes in |
| `tags` | string[] | No | Tags to attach to the contact |
| `metadata` | object | No | Freeform custom data |

> **Note:** There is no `account_id` field — associate the account by its name via `whatsapp_account`.

### Response

Returns the created contact in the same shape as [Get Contact](#get-contact).

When the workspace has reached its [contact limit](#workspace-limits), adding a contact (or bringing back a deleted one) answers `403` with the limit in `data.contact_limit`.

## Update Contact

Update an existing contact.

`PUT /api/contacts/{id}`

### Request Body

Only the fields you send are updated.

```json
{
  "profile_name": "John Smith",
  "whatsapp_account": "Main Business",
  "tags": ["vip", "billing"],
  "metadata": {
    "custom_field": "updated_value"
  },
  "assigned_user_id": "uuid",
  "clear_assigned_agent": false
}
```

| Field | Type | Description |
|-------|------|-------------|
| `profile_name` | string | Display name |
| `whatsapp_account` | string | Name of the WhatsApp number to send from by default |
| `tags` | string[] | Replaces the contact's tags |
| `metadata` | object | Replaces the metadata object |
| `assigned_user_id` | uuid | Assign the contact to an agent |
| `clear_assigned_agent` | boolean | Set `true` to unassign the contact (takes precedence over `assigned_user_id`) |

### Response

Returns the full updated contact (same shape as [Get Contact](#get-contact)).

## Contact Figures

Counts for the contacts you can see, as shown above the contacts list.

`GET /api/contacts/stats`

```json
{
  "status": "success",
  "data": {
    "total": 1240,
    "active_today": 37,
    "new_this_week": 112,
    "opted_out": 9,
    "contact_limit": 5000,
    "workspace_contacts": 1240,
    "import_limit": 50000
  }
}
```

`active_today` counts contacts who wrote in the last 24 hours (their reply window is open); `opted_out` counts those who opted out of marketing messages. `contact_limit` and `import_limit` are the workspace's [limits](#workspace-limits) (`null` when unlimited); with a contact limit, `workspace_contacts` is how many contacts the whole workspace has against it.

## Workspace Limits

A super administrator sets these per workspace, on the workspace's **Limits** tab in platform administration. Each limit uses the platform default until set, and can be set to a number or to unlimited. Lowering a limit keeps what the workspace already has and only stops it adding more.

| Limit | Default | What it stops |
|-------|---------|---------------|
| `contacts` | Unlimited | Adding contacts by hand or by import once the workspace has this many. Messaging never stops at it: customers who write in, the send-message API, campaigns and store automations still create the contact they message |
| `broadcast_messages_per_month` | Unlimited | Sending campaign messages once the workspace has sent this many in the calendar month (UTC). Every message sent counts, retries too; running campaigns pause with the reason. See [Campaigns](https://docs.soosh.io/reference/api/campaigns#monthly-broadcast-limit) |
| `contacts_per_import` | 50,000 | Importing a list with more contacts than this in one go. The app refuses such a file and asks for it to be split; a single request larger than the limit answers `400` |

Super administrators read and change them with `GET /api/admin/organizations/{id}/limits` and `PUT /api/admin/organizations/{id}/limits` (body `{"key": "contacts", "mode": "custom", "limit": 5000}`; `mode` is `default`, `unlimited` or `custom`). Every change is audit-logged.

## Import Contacts

Add or update up to 5,000 contacts in one request; send longer lists in several. Requires the `contacts:import` permission.

`POST /api/contacts/import`

### Request Body

```json
{
  "contacts": [
    { "phone_number": "+91 98765 43210", "name": "Asha Rao", "tags": ["VIP"] },
    { "phone_number": "+44 7700 900123" }
  ],
  "tags": ["Imported Oct 8"],
  "update_existing": true,
  "default_country_code": "91"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `contacts` | object[] | `phone_number` (required), `name`, `tags` and `marketing_opt_out` for each contact. An opt-out is always kept: an import adds one but never clears one |
| `tags` | string[] | Added to every contact in the list |
| `update_existing` | boolean | Default `true`: contacts you already have get the row's name and gain its tags. Tags are only ever added |
| `default_country_code` | string | Added to numbers written without `+` or `00` that have 10 digits or fewer. Without it, such numbers are reported as invalid |
| `dry_run` | boolean | Work out what the import would do (the same counts as below) with reads only: nothing is saved or locked. The app uses it to preview an import |

Numbers are matched to existing contacts however they were stored, so importing never duplicates a contact. A deleted contact is brought back. Repeats within the list are merged into the first. New tags are added to the workspace's tag list; a tag that exists with different capitals takes the existing spelling.

### Response

```json
{
  "status": "success",
  "data": {
    "created": 1,
    "updated": 1,
    "unchanged": 0,
    "duplicates": 0,
    "invalid": 0
  }
}
```

`invalid_samples` and `errors` (such as `Row 3: "call me" isn't a phone number`) are included when some rows were skipped, and `new_tags` lists tags the import adds to the workspace's tag list.

When the workspace reaches its [contact limit](#workspace-limits), new contacts stop there: `over_limit` counts those left out and `contact_limit` gives the limit. Contacts you already have are still updated. Importing the same list again after the limit is raised adds the rest without duplicating anything.

> **Note:** The older `POST /api/import` (multipart, `table=contacts`) uses the same importer for CSV files: it recognises common column names (Phone, Mobile, First name, Last name, Labels, Marketing opt-out…), Excel's semicolon files and Google Contacts exports, and accepts `tags` and `default_country_code` form fields.

## Delete Contact

Delete a contact and all associated data.

`DELETE /api/contacts/{id}`

### Response

```json
{
  "status": "success",
  "data": null
}
```

## Assign Contact

Assign a contact to a team member.

> **Careful:** Requires the `contacts:write` permission, otherwise `403 You do not have permission to assign
> contacts`. The target user must belong to the same organization, otherwise `400 User not found`.

`PUT /api/contacts/{id}/assign`

### Request Body

```json
{
  "user_id": "uuid"
}
```

To unassign a contact, set `user_id` to `null`:

```json
{
  "user_id": null
}
```

### Response

```json
{
  "status": "success",
  "data": {
    "message": "Contact assigned successfully",
    "assigned_user_id": "uuid"
  }
}
```

## Update Contact Tags

Replace the full set of tags on a contact. Requires `contacts:write`.

`PUT /api/contacts/{id}/tags`

### Request Body

```json
{
  "tags": ["vip", "billing"]
}
```

### Response

```json
{
  "status": "success",
  "data": {
    "message": "Contact tags updated",
    "tags": ["vip", "billing"]
  }
}
```

## Mark Contact Read

Mark all incoming messages from a contact as read (clears the unread badge). Used by the chat view when the conversation is open.

`POST /api/contacts/{id}/mark-read`

### Response

```json
{
  "status": "success",
  "data": { "status": "ok" }
}
```

> **Tip:** Use the `metadata` field to store custom data like customer IDs, order numbers, or any business-specific information. Metadata is displayed automatically in the **Contact Info** panel in the chat view.

## Contact Metadata

The `metadata` field is a freeform JSON object that can hold any structured data. It is displayed in the Contact Info panel alongside tags and session data.

### Supported Data Types

Different value types are rendered differently in the panel:

| Type | Display |
|------|---------|
| String, number | Key-value row |
| Boolean | `Yes` / `No` badge |
| Object | Collapsible section with key-value rows |
| Array of objects | Collapsible table with column headers |
| Array of primitives | Inline badges |

### Example

```json
{
  "metadata": {
    "plan": "premium",
    "age": 30,
    "active": true,
    "address": {
      "city": "Mumbai",
      "state": "Maharashtra",
      "zip": "400001"
    },
    "orders": [
      { "id": "ORD-001", "amount": 1500, "status": "delivered" },
      { "id": "ORD-002", "amount": 2300, "status": "pending" }
    ],
    "interests": ["fitness", "tech", "travel"]
  }
}
```

This renders as:

- **General** section — `plan`, `age`, and `active` as key-value rows (boolean shown as a badge)
- **Address** section — collapsible key-value pairs for `city`, `state`, `zip`
- **Orders** section — collapsible table with `Id`, `Amount`, `Status` columns
- **Interests** section — inline badges: `fitness`, `tech`, `travel`

### Nesting Depth

The panel supports **one level of nesting**. Top-level keys are organized as follows:

| Top-level value | Rendered as |
|-----------------|-------------|
| Primitive (string, number, boolean) | Row in the **General** section |
| Object | Its own collapsible section with key-value rows |
| Array of objects | Its own collapsible section with a table |
| Array of primitives | Its own collapsible section with badges |

Values **inside** a nested object or array are always displayed as flat text. If a nested object contains another object, the inner value is shown as a raw JSON string. For example:

```json
{
  "metadata": {
    "address": {
      "city": "Mumbai",
      "location": { "lat": 19.07, "lng": 72.87 }
    }
  }
}
```

Here `city` displays as `Mumbai`, but `location` displays as `{"lat":19.07,"lng":72.87}`.

> **Careful:** Keep metadata **flat or one level deep** for the best display. Deeply nested structures will fall back to raw JSON strings in the panel.

> **Note:** Keys are automatically formatted for display: `snake_case` and `camelCase` are converted to title case (e.g. `first_name` → "First Name", `lastName` → "Last Name").

## Get Session Data

Retrieve chatbot session data for a contact, including collected variables and panel configuration.

`GET /api/contacts/{id}/session-data`

### Response

```json
{
  "status": "success",
  "data": {
    "session_id": "uuid",
    "flow_id": "uuid",
    "flow_name": "Customer Support Flow",
    "session_data": {
      "customer_name": "John Doe",
      "customer_email": "john@example.com",
      "order_id": "ORD-12345",
      "order_status": "shipped"
    },
    "panel_config": {
      "sections": [
        {
          "id": "customer",
          "label": "Customer Info",
          "columns": 1,
          "collapsible": true,
          "default_collapsed": false,
          "order": 1,
          "fields": [
            {"key": "customer_name", "label": "Name", "order": 1},
            {"key": "customer_email", "label": "Email", "order": 2}
          ]
        },
        {
          "id": "order",
          "label": "Order Details",
          "columns": 2,
          "collapsible": true,
          "default_collapsed": true,
          "order": 2,
          "fields": [
            {"key": "order_id", "label": "Order ID", "order": 1},
            {"key": "order_status", "label": "Status", "order": 2}
          ]
        }
      ]
    }
  }
}
```

### Response Fields

| Field | Type | Description |
|-------|------|-------------|
| `session_id` | uuid | The chatbot session ID |
| `flow_id` | uuid | The flow that collected the data |
| `flow_name` | string | Name of the flow |
| `session_data` | object | Key-value pairs of collected variables |
| `panel_config` | object | Panel display configuration from the flow |

> **Note:** This endpoint returns data from the contact's most recent chatbot session. The `panel_config` comes from the flow that was active during that session.

## Messaging & Notes

Contacts are the scope for conversation messages and internal notes.

### Messages

Messages are sent and retrieved under the contact resource. See the [Messages API](https://docs.soosh.io/reference/api/messages) for full request/response details.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/contacts/{id}/messages` | List messages for a contact (paginated) |
| `POST` | `/api/contacts/{id}/messages` | Send a message to the contact |
| `POST` | `/api/contacts/{id}/messages/{message_id}/reaction` | Add or remove an emoji reaction on a message |

### Conversation Notes

Internal, contact-scoped notes visible to agents (never sent to the customer). Requires `chat:read` to list and `chat:write` to modify.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/contacts/{id}/notes` | List notes for a contact (cursor-paginated, oldest first) |
| `POST` | `/api/contacts/{id}/notes` | Create a note |
| `PUT` | `/api/contacts/{id}/notes/{note_id}` | Update a note |
| `DELETE` | `/api/contacts/{id}/notes/{note_id}` | Delete a note |

#### Create Note Request Body

```json
{
  "content": "Customer prefers email follow-up."
}
```

#### Note Response

```json
{
  "status": "success",
  "data": {
    "id": "uuid",
    "contact_id": "uuid",
    "created_by_id": "uuid",
    "created_by_name": "Admin User",
    "content": "Customer prefers email follow-up.",
    "created_at": "2024-01-01T12:00:00Z",
    "updated_at": "2024-01-01T12:00:00Z"
  }
}
```

The list endpoint returns `{ "notes": [...], "total": <n>, "has_more": <bool> }`.

## AI Reply Suggestions

Drafts of the agent's next reply, written by AI from the conversation, the workspace's AI instructions and knowledge, and the customer's memory. Nothing is sent: the agent picks one, edits it if needed and sends it as usual. Requires `chat:write` and access to the contact (agents only their own chats). The workspace needs the **AI reply suggestions** component and AI replies and knowledge allocated; each call is metered as AI usage (`suggest`), at most 120 per agent per hour by default. An agent limited to the current conversation gets drafts from it only (none before a conversation on the contact's number has started), and not from the customer's memory.

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/contacts/{id}/ai/suggestions` | Write 1–3 drafts of the next reply |
| `POST` | `/api/ai/events/{event_id}/feedback` | Say whether one of them was sent |

The AI reads the last 20 messages. The request body is empty.

```json
{
  "status": "success",
  "data": {
    "suggestions": ["It ships tomorrow; you'll get the tracking link by WhatsApp.", "Let me check with the warehouse and get back to you today."],
    "event_id": "uuid"
  }
}
```

| Status | When |
|--------|------|
| `400` | The customer hasn't written yet: nothing to reply to |
| `402` | The wallet has no credit left for AI, or the monthly AI limit is reached |
| `403` | AI reply suggestions (or AI) are off for the workspace |
| `404` | Contact not found, or not one the agent may open |
| `429` | Too many suggestions asked for; try again later |
| `502` | The AI's answer couldn't be read, was cut off or empty (not charged); try again |
| `503` | AI isn't available right now |

### Feedback

Send `{"used": true, "edit_distance": 12}` when the agent sends one of the drafts (`edit_distance`, optional: how many characters they changed first, 0 to 4,096), `{"used": false}` when they dismiss them. Only the agent who asked can answer, once; `true` replaces an earlier `false`. Returns `404` for an unknown event or another agent's.

`GET /api/ai/status` says `"suggestions": true` when the member can ask for them: the workspace has them and AI on, and the member has `chat:write`.
