Skip to content

Import contacts and tag them

This guide puts your customers into your SanadWA account as contacts, and labels them with tags. A tag is a name, such as VIP or Riyadh customers, that you put on contacts. You can later send a campaign to everyone with a tag.

You need an API key with the full scope: creating tags and writing contacts change data in your account. A send key gets 403 api_scope_insufficient. See Scopes. The examples read the key from the environment variable SANADWA_API_KEY.

  • The phone number identifies a contact. Your account has at most one contact per phone number. Writing a phone number that your account does not have yet creates a contact. Writing one it already has updates that contact.
  • Send phone numbers in E.164 format, with the country code: +12025550123.
  • A tag must exist in your account’s tag list before you can put it on a contact. Create it first with POST /tags. Tag names are compared without regard to case: vip finds the tag VIP.
  • When a contact is updated:
    • a new first_name replaces the stored one;
    • last_name and email are filled in only when the contact has none, so a stored value is kept;
    • tags are added to the contact’s tags. Nothing is removed.
    • A field you leave out, or send empty, is left as it is.
  • A contact’s opt-out from marketing messages is never changed by these endpoints. If a person opted out, importing their number again does not opt them back in.
Terminal window
curl https://api.sanadwa.com/v1/tags \
-H "Authorization: Bearer $SANADWA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "VIP", "description": "Customers with more than 10 orders" }'

A new tag answers 201:

{
"success": true,
"data": {
"id": "17",
"name": "VIP",
"description": "Customers with more than 10 orders",
"created_at": "2026-11-11T08:00:00.000Z"
}
}

Rules for tag names: 1 to 100 characters, and no comma (,) or semicolon (;). The description is optional, at most 500 characters.

If a tag with the same name already exists (in any case), you get 409 tag_already_exists. That is fine: the tag is there. To see all your tags, call GET /tags (paginated, see Pagination).

Ids in this API, like "17", are strings. Store them as strings.

POST /contacts creates or updates 1 to 100 contacts in one request. The body may be up to 1 MB.

Field Required Rules
phone Yes International format, for example +12025550123. At most 32 characters.
first_name No At most 100 characters.
last_name No At most 100 characters.
email No A valid email address, at most 254 characters.
tags No A list of at most 10 tag names that exist in your account.
Terminal window
curl https://api.sanadwa.com/v1/contacts \
-H "Authorization: Bearer $SANADWA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"contacts": [
{ "phone": "+12025550123", "first_name": "Sara", "email": "sara@example.com", "tags": ["VIP"] },
{ "phone": "+12025550124", "first_name": "Omar", "tags": ["VIP"] }
]
}'

Each contact is handled on its own. The answer is 200 even when some contacts were not saved, with one result per contact, in the order you sent them:

{
"success": true,
"data": {
"summary": { "created": 1, "updated": 0, "rejected": 1, "failed": 0 },
"results": [
{
"index": 0,
"status": "created",
"contact": {
"id": "5012",
"phone": "+12025550123",
"first_name": "Sara",
"last_name": null,
"email": "sara@example.com",
"tags": ["VIP"],
"created_at": "2026-11-11T08:01:00.000Z",
"updated_at": "2026-11-11T08:01:00.000Z"
},
"error": null
},
{
"index": 1,
"status": "rejected",
"contact": null,
"error": {
"code": "tag_not_found",
"message": "These tags are not in your account's tag list: Vip customers. Create them with POST /tags first."
}
}
]
}
}
status Meaning What to do
created A new contact was saved. Nothing.
updated A contact with this phone already existed and was updated. Nothing.
rejected Not saved, because of the contact itself. error.code says why: validation_error (a field is wrong; error.message names it), duplicate_phone (the same phone is earlier in this request), or tag_not_found (a tag does not exist). Fix the contact and send it again.
failed Not saved, because of a temporary problem on our side (contact_write_failed). Send it again.

If the body itself is wrong, for example contacts is missing, empty or has more than 100 items, the answer is 400 validation_error and nothing is saved.

Always check summary and each result’s status. A 200 alone does not mean every contact was saved.

Sending the same contacts again is safe: the phone number decides which contact is updated, so a retry never creates duplicates. With the same Idempotency-Key and the same body, a retry within 24 hours returns the first answer. See Idempotency.

For many contacts, upload a CSV file to POST /contacts/import. Send it as multipart/form-data:

Form field Required Meaning
file Yes The CSV file, UTF-8, at most 5 MB and at most 5,000 contact rows.
tags No Tags to add to every row, as one comma-separated list, for example VIP,Newsletter. At most 50 names. They must exist in your account.

The first line of the file is the header. These columns are read; others are ignored:

Column Also accepted as Meaning
phone phone_number, phoneNumber Required in every row.
first_name firstName, firstname Optional.
last_name lastName, lastname Optional.
email Optional.
tags tag Optional tags for this row only, separated by , or ; (put the cell in quotes when it contains a comma). At most 10 per row.
phone,first_name,last_name,email,tags
+12025550123,Sara,,sara@example.com,VIP
+12025550124,Omar,Ali,,"VIP;Newsletter"
+12025550125,Lina,,,
Terminal window
curl https://api.sanadwa.com/v1/contacts/import \
-H "Authorization: Bearer $SANADWA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "file=@customers.csv;type=text/csv" \
-F "tags=Newsletter"

The import runs while you wait and answers when every row is handled, so a large file can take a while. Use a generous timeout. The answer is 200 with counts, and one entry for each row that was not saved:

{
"success": true,
"data": {
"summary": { "rows": 3, "created": 2, "updated": 0, "rejected": 1, "failed": 0 },
"errors": [
{
"row": 4,
"status": "rejected",
"code": "validation_error",
"message": "\"phone\" is required."
}
]
}
}

row is the row’s position counting the header as row 1. It matches the line number in the file when the file has no blank lines and no line breaks inside cells. Each row is handled like one contact of POST /contacts, with the same rejected and failed codes.

Some problems stop the whole import, and then nothing is saved:

Code HTTP Why
import_file_missing 400 No file in the file field.
import_file_too_large 413 The file is larger than 5 MB. Split it.
import_file_not_csv 415 The file is an Excel workbook. Save it as CSV (UTF-8).
import_csv_invalid 400 The file is not valid CSV (check quotes and separators).
import_csv_empty 400 The file has no contact rows.
import_too_many_rows 400 More than 5,000 rows. Split the file.
tag_not_found 404 A name in the tags form field is not in your tag list. details.tags lists them.
validation_error 400 The form is wrong, for example more than 50 names in tags, or a file in an unexpected field.

With an Idempotency-Key, a retry with the same file and the same tags returns the first answer. A different file with the same key answers 409 idempotency_conflict.

List the contacts that carry a tag with GET /contacts?tag=:

Terminal window
curl "https://api.sanadwa.com/v1/contacts?tag=VIP&limit=200" \
-H "Authorization: Bearer $SANADWA_API_KEY"
  • The list is sorted by phone number and is paginated. Read every page as described in Pagination.
  • tag matches the whole tag name, without regard to case. Without tag, you get all contacts.
  • There is no filter by phone number: phone numbers are personal data, and URLs are written to logs. To get the contact for one phone number, send just that phone to POST /contacts. The result is the existing contact (nothing changes except its updated_at), or a new contact if the number was not in your account.

Delete a contact with DELETE /contacts/{id}, using the id from a result above:

Terminal window
curl -X DELETE https://api.sanadwa.com/v1/contacts/5012 \
-H "Authorization: Bearer $SANADWA_API_KEY"
{ "success": true, "data": { "id": "5012", "deleted": true } }

A contact who opted out of marketing messages cannot be deleted. Deleting them would erase the opt-out, and the number could then receive marketing again. The API answers 409 contact_opted_out.

Delete a tag with DELETE /tags/{id}. This removes the name from your tag list only: contacts that carry the tag keep it, and GET /contacts?tag= still finds them. You cannot put the name on contacts again until you create the tag again.

An unknown id, or an id from another account, answers 404 contact_not_found or 404 tag_not_found.

Send a campaign to a tag.