Skip to content
Contents
Workspace data

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

GET/v1/contactsPOST/v1/contactsGET/v1/contacts/{contactId}PATCH/v1/contacts/{contactId}DELETE/v1/contacts/{contactId}

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
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

FieldTypeRequiredDescription
phonestringRequiredThe number. Normalised to E.164 on write, and immutable afterwards.
namestringOptionalThe display name.
first_name, last_namestringOptionalGiven and family name. If there is no display name, one is composed from these.
emailstringOptionalLower-cased on write.
localestringOptionalA language tag such as ar or en-GB.
notesstringOptionalFree text.
opt_in_status"unknown" | "opted_in" | "opted_out"OptionalMarketing consent. Defaults to unknown.
attributesobject<string, string>OptionalYour own key/value data. Keys are lower_snake_case; the count and the value length are both bounded.
tagsstring[]OptionalTag NAMES, not ids. Unknown names are created.
group_idsstring[]OptionalGroup ids to add the contact to.
default_calling_codestringOptionalUsed 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

ParameterValuesDescription
qstringCase-insensitive search over name, email and phone. A needle with three or more digits also matches the number.
tagtag_… | slugA tag public id or its slug — both work.
group_idgrp_…Contacts in one group.
opt_in_statusunknown, opted_in, opted_outFilter by consent.
sourcemanual, import, api, inbound, campaign, automation, systemHow the contact entered the workspace.
limit, afterCursor pagination, newest first.

An unrecognised value for opt_in_status or source is a 422 naming the allowed set, not an ignored filter.

Groups

GET/v1/contact-groupsPOST/v1/contact-groupsGET/v1/contact-groups/{groupId}PATCH/v1/contact-groups/{groupId}DELETE/v1/contact-groups/{groupId}GET/v1/contact-groups/{groupId}/membersPOST/v1/contact-groups/{groupId}/membersDELETE/v1/contact-groups/{groupId}/members

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.

Tags

GET/v1/tagsPOST/v1/tagsDELETE/v1/tags/{tagId}

A tag has a name, a generated slug and an optional colour. Contacts reference tags by name on write and receive them as objects on read.

Importing a spreadsheet

POST/v1/contacts/importGET/v1/contacts/importsGET/v1/contacts/imports/{importId}

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.

curl
# 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

LimitValue
Formatscsv, xlsx
Decoded file size5 MiB
Rows per run50 000
Row errors listed100
Stored file retention7 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.

reasonDescription
missing_phoneThe row has no phone number.
invalid_phoneThe number could not be normalised to E.164.
duplicate_in_fileThe number appeared earlier in the same file.
row_too_wideThe row has more columns than the header.
unmapped_headerA header could not be mapped and could not become an attribute.