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.
How contacts work
Section titled “How contacts work”- 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:vipfinds the tagVIP. - When a contact is updated:
- a new
first_namereplaces the stored one; last_nameandemailare 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 new
- 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.
Step 1: Create the tags
Section titled “Step 1: Create the tags”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" }'const res = await fetch("https://api.sanadwa.com/v1/tags", { method: "POST", headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "VIP", description: "Customers with more than 10 orders" }),});console.log(res.status, await res.json());import osimport requests
res = requests.post( "https://api.sanadwa.com/v1/tags", headers={"Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}"}, json={"name": "VIP", "description": "Customers with more than 10 orders"}, timeout=30,)print(res.status_code, res.json())<?php$ch = curl_init("https://api.sanadwa.com/v1/tags");curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("SANADWA_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode(["name" => "VIP", "description" => "Customers with more than 10 orders"]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,]);$body = curl_exec($ch);echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), " ", $body, PHP_EOL;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.
Step 2a: Add contacts as JSON
Section titled “Step 2a: Add contacts as JSON”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. |
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"] } ] }'import { randomUUID } from "node:crypto";
const res = await fetch("https://api.sanadwa.com/v1/contacts", { method: "POST", headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": randomUUID(), }, body: JSON.stringify({ contacts: [ { phone: "+12025550123", first_name: "Sara", email: "sara@example.com", tags: ["VIP"] }, { phone: "+12025550124", first_name: "Omar", tags: ["VIP"] }, ], }),});const { data } = await res.json();console.log(data.summary);for (const r of data.results) { if (r.status === "rejected" || r.status === "failed") console.log(r.index, r.error.code, r.error.message);}import osimport uuidimport requests
res = requests.post( "https://api.sanadwa.com/v1/contacts", headers={ "Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "contacts": [ {"phone": "+12025550123", "first_name": "Sara", "email": "sara@example.com", "tags": ["VIP"]}, {"phone": "+12025550124", "first_name": "Omar", "tags": ["VIP"]}, ] }, timeout=60,)data = res.json()["data"]print(data["summary"])for r in data["results"]: if r["status"] in ("rejected", "failed"): print(r["index"], r["error"]["code"], r["error"]["message"])<?php$payload = ["contacts" => [ ["phone" => "+12025550123", "first_name" => "Sara", "email" => "sara@example.com", "tags" => ["VIP"]], ["phone" => "+12025550124", "first_name" => "Omar", "tags" => ["VIP"]],]];$ch = curl_init("https://api.sanadwa.com/v1/contacts");curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("SANADWA_API_KEY"), "Content-Type: application/json", "Idempotency-Key: " . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60,]);$data = json_decode(curl_exec($ch), true)["data"];print_r($data["summary"]);foreach ($data["results"] as $r) { if (in_array($r["status"], ["rejected", "failed"], true)) { echo $r["index"], " ", $r["error"]["code"], " ", $r["error"]["message"], PHP_EOL; }}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.
Step 2b: Import a CSV file
Section titled “Step 2b: Import a CSV file”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,,,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"import { readFile } from "node:fs/promises";import { randomUUID } from "node:crypto";
const form = new FormData();form.append("file", new Blob([await readFile("customers.csv")], { type: "text/csv" }), "customers.csv");form.append("tags", "Newsletter");
// Do not set Content-Type yourself: fetch adds it with the multipart boundary.const res = await fetch("https://api.sanadwa.com/v1/contacts/import", { method: "POST", headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}`, "Idempotency-Key": randomUUID(), }, body: form,});console.log(res.status, JSON.stringify(await res.json(), null, 2));import osimport uuidimport requests
with open("customers.csv", "rb") as f: res = requests.post( "https://api.sanadwa.com/v1/contacts/import", headers={ "Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, files={"file": ("customers.csv", f, "text/csv")}, data={"tags": "Newsletter"}, timeout=300, )print(res.status_code, res.json())<?php$ch = curl_init("https://api.sanadwa.com/v1/contacts/import");curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("SANADWA_API_KEY"), "Idempotency-Key: " . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => [ "file" => new CURLFile("customers.csv", "text/csv", "customers.csv"), "tags" => "Newsletter", ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300,]);$body = curl_exec($ch);echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), " ", $body, PHP_EOL;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.
Step 3: Check the result
Section titled “Step 3: Check the result”List the contacts that carry a tag with GET /contacts?tag=:
curl "https://api.sanadwa.com/v1/contacts?tag=VIP&limit=200" \ -H "Authorization: Bearer $SANADWA_API_KEY"const url = new URL("https://api.sanadwa.com/v1/contacts");url.searchParams.set("tag", "VIP");url.searchParams.set("limit", "200");const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}` } });const { data } = await res.json();console.log(data.length, "contacts on this page");res = requests.get( "https://api.sanadwa.com/v1/contacts", params={"tag": "VIP", "limit": 200}, headers={"Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}"}, timeout=30,)print(len(res.json()["data"]), "contacts on this page")<?php$ch = curl_init("https://api.sanadwa.com/v1/contacts?" . http_build_query(["tag" => "VIP", "limit" => 200]));curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SANADWA_API_KEY")], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,]);echo count(json_decode(curl_exec($ch), true)["data"]), " contacts on this page", PHP_EOL;- The list is sorted by phone number and is paginated. Read every page as described in Pagination.
tagmatches the whole tag name, without regard to case. Withouttag, 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
phonetoPOST /contacts. The result is the existing contact (nothing changes except itsupdated_at), or a new contact if the number was not in your account.
Remove a contact or a tag
Section titled “Remove a contact or a tag”Delete a contact with DELETE /contacts/{id}, using the id from a result above:
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.