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, notes
  • tags: up to 20 free-form labels
  • fields: 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).

bash
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" } }'
json
{
  "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"
}
ParameterTypeNotes
phone_numberstringRequired. US or Canada; stored as E.164.
namestringOptional, up to 120 characters.
tagsstring[]Optional. Up to 20, 40 characters each.
fieldsobjectOptional. Up to 20 string values. Keys up to 40 characters, no . # $ / [ ].
notesstringOptional, up to 2,000 characters.
first_name, last_namestringOptional, up to 80 characters each. name defaults to the two joined.
emailstringOptional. Must look like an email address.
company, state, sourcestringOptional. 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/import takes { "contacts": [ ... ] }, up to 500 per request, and answers { "created", "updated", "skipped" }. A row that can't be imported is listed in skipped with its index and the reason; the rest still land.
  • POST /v1/contacts/bulk applies 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 in missing.
  • GET /v1/contacts?limit=25&cursor=... lists newest first; ?tag=vip narrows to a tag, ?phone_number=+14155550132 finds 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 its consent_status, its topic subscriptions, and the segments it is in. Here and on PATCH and DELETE, {id} may also be the contact's phone number.
  • PATCH /v1/contacts/{id} changes only the fields you send. Here tags and fields replace what is stored (this is how you remove a tag), and null clears name, notes or any other optional property. Moving a contact to a number another contact holds answers 409.
  • 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:

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

bash
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"}
      ]}'
fieldOperatorsNotes
tagis, is_not, exists, not_existshas / lacks the tag
first_name, last_name, name, email, company, state, sourceis, is_not, contains, not_contains, exists, not_existscase-insensitive
field (+ key)same as abovea custom field
topic (+ key = topic id)is, is_notvalue subscribed or unsubscribed
statusis, is_notvalue subscribed or opted_out
importis, is_notvalue = an import_id
created_atbefore, afteran 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.

bash
# 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_id skips 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_status on 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, in GET /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.

bash
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 '.

bash
curl "https://api.joinsimplesms.com/v1/exports/deliveries?status=failed&from=2026-09-01" \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" -o failed.csv

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