Contacts
A contact is a phone number with properties, consent, and history. Contacts
are keyed by phone number: one contact per number per account (up to 50,000),
and every write is an upsert rather than a duplicate. Everything on this page
is in the console and on the API (scope: contacts).
Fields
phone_number(E.164),name,first_name,last_name,email,company,state,source,notestags: up to 20 free-form labelsfields: up to 20 custom key/values (account id, plan, location...), usable in segments and in merge fields as{{field:key}}
The console's contact page adds the history: messages, consent events, and the broadcasts the number was in.
API
Sync contacts from your own database instead of uploading files. Scope:
contacts.
POST /v1/contacts creates a contact, or updates the one that already has
that number (201 created, 200 updated).
curl -X POST https://api.joinsimplesms.com/v1/contacts \
-H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone_number": "+14155550132", "name": "Ada", "tags": ["vip"], "fields": { "plan": "pro" } }'{
"id": "ct_a1B2c3D4e5F6",
"object": "contact",
"phone_number": "+14155550132",
"name": "Ada",
"tags": ["vip"],
"fields": { "plan": "pro" },
"notes": null,
"created_at": "2026-10-04T12:00:00.000Z",
"updated_at": "2026-10-04T12:00:00.000Z"
}| Parameter | Type | Notes |
|---|---|---|
phone_number | string | Required. US or Canada; stored as E.164. |
name | string | Optional, up to 120 characters. |
tags | string[] | Optional. Up to 20, 40 characters each. |
fields | object | Optional. Up to 20 string values. Keys up to 40 characters, no . # $ / [ ]. |
notes | string | Optional, up to 2,000 characters. |
first_name, last_name | string | Optional, up to 80 characters each. name defaults to the two joined. |
email | string | Optional. Must look like an email address. |
company, state, source | string | Optional. source is where the contact came from and defaults to api. |
The response also carries these, plus import_id and opt_in (the
opt-in evidence an import stored), as null when unset.
POST /v1/contacts/importtakes{ "contacts": [ ... ] }, up to 500 per request, and answers{ "created", "updated", "skipped" }. A row that can't be imported is listed inskippedwith itsindexand the reason; the rest still land.POST /v1/contacts/bulkapplies one action to up to 500 contact ids:{ "action": "add_tags" | "remove_tags" | "delete", "ids": [...], "tags": [...] }. It answers{ "updated", "deleted", "missing" }; ids that don't exist are counted inmissing.GET /v1/contacts?limit=25&cursor=...lists newest first;?tag=vipnarrows to a tag,?phone_number=+14155550132finds one by number.?segment_id=narrows to a saved segment and?q=searches name, email, company and digits.GET /v1/contacts/{id}retrieves one, with itsconsent_status, its topic subscriptions, and the segments it is in. Here and onPATCHandDELETE,{id}may also be the contact's phone number.PATCH /v1/contacts/{id}changes only the fields you send. Heretagsandfieldsreplace what is stored (this is how you remove a tag), andnullclearsname,notesor any other optional property. Moving a contact to a number another contact holds answers409.DELETE /v1/contacts/{id}removes the contact. Messages and opt-out records for the number are untouched.
POST and import follow the CSV rule: existing contacts are enriched,
never wiped. Values you leave out are kept and tags are added.
CSV import
Console → Contacts → Import. Drop a CSV: phone, first name, last name, email, company, state, tags, opt-in status, opt-in date and source are detected from the headers (or, failing that, from the values), and every other column becomes a custom field. You can change any mapping or skip a column.
Check this file is a dry run. Nothing is written; you get a line like:
18,421 contacts found · 17,982 valid mobile numbers · 231 landlines · 208 malformed · 16,904 eligible to receive a broadcast
plus how many will be added and updated, and a CSV of the rows left out with the reason for each.
- Malformed rows are not US or Canada numbers. Duplicates keep the first row for each number.
- Landlines are only known if you tick "Check line types". On a live account each lookup is a paid carrier query, so only the first 500 numbers are checked; in sandbox the check is free and uses the sandbox fixture. Without it the line says "valid numbers", not "valid mobile numbers". Landlines found are left out of the import.
- Already opted out numbers are imported and stay opted out. An import never opts anyone back in.
- Opt-in columns: a row marked opted in (or carrying an opt-in date or source) stores that as evidence in the consent ledger. A row marked unsubscribed is imported and recorded as an opt-out.
- Existing contacts are enriched, never wiped: a row with only a name and phone will not erase tags you added by hand.
Files are read in the browser in pieces and committed 500 rows per request. The ceiling is 50,000 rows (the contact limit) and 25 MB.
After an import: Create a segment from these contacts, or Send a broadcast to them.
From your own system, the same thing in JSON:
curl -X POST https://api.joinsimplesms.com/v1/contacts/import \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dry_run": true, "contacts": [
{"phone_number": "+14155550132", "first_name": "Jane",
"opt_in": {"status": true, "at": "2026-03-01", "source": "web_form"}}
]}'Up to 500 contacts per request. Without dry_run it upserts and also
returns import_id; pass that on later requests to group one import.
Segments
A segment is a saved, named filter over contacts: an audience. (Not to be confused with the parts a long SMS is split into, which the message composer also calls segments.) It is evaluated when it is used, so a contact tagged tomorrow is in tomorrow's broadcast.
curl -X POST https://api.joinsimplesms.com/v1/segments \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Tennessee Pro, marketing on", "match": "all", "rules": [
{"field": "state", "op": "is", "value": "TN"},
{"field": "field", "key": "plan", "op": "is", "value": "pro"},
{"field": "topic", "key": "tp_marketing", "op": "is", "value": "subscribed"}
]}'field | Operators | Notes |
|---|---|---|
tag | is, is_not, exists, not_exists | has / lacks the tag |
first_name, last_name, name, email, company, state, source | is, is_not, contains, not_contains, exists, not_exists | case-insensitive |
field (+ key) | same as above | a custom field |
topic (+ key = topic id) | is, is_not | value subscribed or unsubscribed |
status | is, is_not | value subscribed or opted_out |
import | is, is_not | value = an import_id |
created_at | before, after | an ISO date |
match is all or any; up to 10 rules per segment and 100 segments.
GET /v1/segments/{id}/preview?topic_id=tp_marketing returns matched,
opted_out, topic_unsubscribed and eligible (they add up).
GET /v1/contacts?segment_id= lists the members, and
POST /v1/batches takes segment_id as its audience.
Segments are evaluated in memory over your contacts, their opt-out state and their topic preferences; there is no per-rule cost.
Subscription topics
Topics let a person stop one kind of message without stopping all of them.
Every account starts with Marketing (tp_marketing), Account alerts
(tp_account_alerts) and Product updates (tp_product_updates); add,
rename or delete them (up to 20) under Contacts → Segments or with
/v1/topics.
# Unsubscribe a number from Marketing only
curl -X PUT https://api.joinsimplesms.com/v1/contacts/+14155550132/topics/tp_marketing \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"subscribed": false, "source": "preference_page"}'- No record means subscribed. A topic only ever narrows who you text.
- A broadcast or batch sent with a
topic_idskips anyone unsubscribed from it, at validation and again at send time. - STOP still stops everything. An opted-out number gets nothing whatever
its topics say, and subscribing someone to a topic does not opt them back
in.
consent_statuson the contact is the one to check first. - Preferences belong to the phone number, not the contact record: they survive deleting the contact, and work for numbers that are not contacts.
- Every change is written to the consent ledger (
topic_unsubscribe/topic_subscribe, inGET /v1/consent/{phone}and the ledger export) and to the audit log.
Bulk edits
Tick contacts (or "select all shown" after filtering) to add or remove tags or delete them in one go. Tags on a row edits that contact's tags inline.
Export
Console → Contacts → Export downloads the whole book as CSV, custom fields as columns.
Templates
Console → Templates (or /v1/templates) stores reusable bodies with merge
fields: {{first_name}}, {{name}}, {{phone}}, {{field:company}}.
An unknown field like {{nickname}} renders as an empty string; text that is
not merge syntax at all ({{Name}}, uppercase) is sent exactly as typed, and
the console editor flags both. It also previews the rendered text against a
sample contact, with length and segment count.
curl -X POST https://api.joinsimplesms.com/v1/templates \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Order ready", "body": "Hi {{first_name}}, your order is ready."}'GET /v1/templates, GET|PATCH|DELETE /v1/templates/{id} complete the set.
Exports and saved views
GET /v1/exports/{kind} streams CSV for messages, deliveries
(outbound status + failure reason), events, opt_outs,
webhook_deliveries, usage (daily counters), usage_monthly and
audit_log (the audit log, with its own filters), with
from/to, status, number and type filters. Exports are capped
at 50,000 rows; narrow the date range for more. Cells that a spreadsheet would
run as a formula (=, +, -, @) are prefixed with '.
curl "https://api.joinsimplesms.com/v1/exports/deliveries?status=failed&from=2026-09-01" \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" -o failed.csvThe console's Messages, Events and Contacts pages export the current filter, and Save view keeps a filter set for one click later (views are personal to your login).
Names in the inbox
Inbound messages resolve the sender against contacts, so threads show "Jane Doe" instead of a raw number the moment a contact exists.