# Contacts

Source: https://joinsimplesms.com/docs/contacts
Index: https://joinsimplesms.com/llms.txt

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"
}
```

| 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/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](/docs/opt-out) 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](/docs/sandbox). 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](/docs/opt-out). 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"}
      ]}'
```

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

```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](/docs/audit-logs).

## 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](/docs/audit-logs), 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.
