Contacts
Contacts are the people a workspace messages, keyed by phone number. The interesting part is the bulk import: what it deduplicates on, and what it does when it finds a match.
Endpoints
A contact is unique per workspace by its normalised E.164 number. Deleting one is a soft delete, and creating or importing the same number afterwards restores it rather than making a second row.
curl -X POST https://whats.azzamkh.sa/api/v1/contacts \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+9665XXXXXXXX",
"name": "سارة الأحمد",
"email": "sara@example.com",
"locale": "ar",
"opt_in_status": "opted_in",
"tags": ["vip"],
"attributes": { "customer_id": "A-10428" }
}'
# 201 Created
# { "id": "cnt_...", "phone": "+9665XXXXXXXX", "source": "api", ... }
# The number already exists and is live:
# 409 { "error": { "code": "CONTACT_ALREADY_EXISTS",
# "details": { "contact_id": "cnt_..." } } }Fields
| Field | Type | Required | Description |
|---|---|---|---|
| phone | string | Required | The number. Normalised to E.164 on write, and immutable afterwards. |
| name | string | Optional | The display name. |
| first_name, last_name | string | Optional | Given and family name. If there is no display name, one is composed from these. |
| string | Optional | Lower-cased on write. | |
| locale | string | Optional | A language tag such as ar or en-GB. |
| notes | string | Optional | Free text. |
| opt_in_status | "unknown" | "opted_in" | "opted_out" | Optional | Marketing consent. Defaults to unknown. |
| attributes | object<string, string> | Optional | Your own key/value data. Keys are lower_snake_case; the count and the value length are both bounded. |
| tags | string[] | Optional | Tag NAMES, not ids. Unknown names are created. |
| group_ids | string[] | Optional | Group ids to add the contact to. |
| default_calling_code | string | Optional | Used to normalise a number written in national format, e.g. 966. |
null clears, absent leaves alone
On an update, the nullable text fields distinguish the two: sending null clears the column, and omitting the field leaves it as it was. Sending tags or group_ids replaces the whole set rather than adding to it.
Filters
| Parameter | Values | Description |
|---|---|---|
| q | string | Case-insensitive search over name, email and phone. A needle with three or more digits also matches the number. |
| tag | tag_… | slug | A tag public id or its slug — both work. |
| group_id | grp_… | Contacts in one group. |
| opt_in_status | unknown, opted_in, opted_out | Filter by consent. |
| source | manual, import, api, inbound, campaign, automation, system | How the contact entered the workspace. |
| limit, after | — | Cursor pagination, newest first. |
An unrecognised value for opt_in_status or source is a 422 naming the allowed set, not an ignored filter.
Groups
A group is a static list. Membership is edited in bulk, and the remove endpoint takes a body on a DELETE — unusual, but it is what lets you remove a thousand contacts in one call.
Importing a spreadsheet
The file travels base64-encoded inside JSON: there is no multipart endpoint. The import is accepted with a 202 and runs in the background, so poll the run for progress and results. Send dry_run: true first — it parses, validates and counts without writing anything, which is the only safe way to find out what a file will do.
# The file travels base64-encoded inside JSON. There is no multipart route.
CONTENT=$(base64 < contacts.csv | tr -d '\n')
curl -X POST https://whats.azzamkh.sa/api/v1/contacts/import \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"filename\": \"contacts.csv\",
\"format\": \"csv\",
\"content\": \"$CONTENT\",
\"dry_run\": true,
\"default_calling_code\": \"966\"
}"
# 202 Accepted — the import runs in the background. Poll it:
curl https://whats.azzamkh.sa/api/v1/contacts/imports/imp_... \
-H "Authorization: Bearer $WA_API_KEY"
# {
# "id": "imp_...",
# "status": "completed",
# "dry_run": true,
# "progress": { "total_rows": 1200, "processed_rows": 1200, "fraction": 1 },
# "result": { "created": 1140, "updated": 48, "duplicates": 52, "invalid": 8 },
# "errors": [
# { "row": 74, "column": "phone", "reason": "invalid_phone", "value": "05x1234" }
# ]
# }Limits
| Limit | Value |
|---|---|
| Formats | csv, xlsx |
| Decoded file size | 5 MiB |
| Rows per run | 50 000 |
| Row errors listed | 100 |
| Stored file retention | 7 d |
The format is detected from the file's magic bytes rather than its extension, so a spreadsheet saved with the wrong suffix still imports correctly. The older .xls format is not supported.
Deduplication
Deduplication happens twice, on different keys, and the two behave differently. Both key on the normalised phone number.
- Within one file, repeated numbers are merged rather than dropped: later non-empty values win, tags are unioned, and attributes are merged. The row is counted as a duplicate but the edit is kept.
- Against the workspace, a matching number is UPDATED, never skipped. An import is an upsert.
- Only non-empty values overwrite. A blank column never erases data you already had — which is what makes a partial spreadsheet safe to import.
- A soft-deleted contact whose number appears in the file is restored.
The four counts do not add up — deliberately
total_rows = created + duplicates + invalid. updated is a sub-count of duplicates, not a fourth category, so summing all four double-counts every updated row.
Column mapping
Headers are matched against English and Arabic aliases, so a file with a column headed رقم الجوال maps to the phone field with no configuration. Send an explicit mapping to override that. A column that maps to nothing becomes a custom attribute rather than being discarded.
| reason | Description |
|---|---|
| missing_phone | The row has no phone number. |
| invalid_phone | The number could not be normalised to E.164. |
| duplicate_in_file | The number appeared earlier in the same file. |
| row_too_wide | The row has more columns than the header. |
| unmapped_header | A header could not be mapped and could not become an attribute. |