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.jointextkit.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, or marked yes in an opted-out column (opted_out, unsubscribed, do_not_text, dnc), is imported and recorded as an opt-out. A campaign_id column (a registration id, reg_...) records the opt-in for that registration, which is what a marketing send from its numbers looks for (consent is per registration). For a marketing registration the row must also carry an opt-in date, a recognised source, and the page for a web form (opt_in_page_url); otherwise it is skipped with the reason.
  • 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.jointextkit.com/v1/contacts/import \
  -H "Authorization: Bearer $TEXTKIT_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",
                    "page_url": "https://acme.example/texts", "campaign_id": "reg_a1B2c3D4e5F6"}}
      ]}'

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. A top-level campaign_id applies to every opt-in row that does not name its own. A top-level list_id adds every contact in the request to that list.

The console's import also takes Excel files (.xlsx, the first sheet).

Lists

A list is a fixed group of contacts: people are in it because you put them there, by hand, by an import, or with the API. A segment is the rule-based kind. A contact can be in many lists, and a segment can use a list as a rule ({"field": "list", "op": "is", "value": "lst_..."}).

bash
curl -X POST https://api.jointextkit.com/v1/lists \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Spring promo"}'

curl -X POST https://api.jointextkit.com/v1/lists/lst_a1B2c3D4e5F6/contacts \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone_numbers": ["+14155550132"], "contact_ids": ["ct_a1B2c3D4e5F6"]}'

Up to 1,000 contacts per request; numbers that are not contacts yet come back in not_found. DELETE on the same path with the same body removes them (they stay contacts). GET /v1/lists/{id}/contacts pages through the members, and GET /v1/contacts?list_id= filters by list. Send a batch to a list with list_id instead of recipients.

Integrations

Connect a tool in the console (Audience → Integrations) and its people arrive as contacts, in a list of their own that stays in step with the source:

ToolWhat syncsConsent
ShopifyCustomers, or a customer search like tag:vip; abandoned checkouts, orders and shipments for cart reminders and order textsShopify's SMS marketing state
HubSpotContacts, or one listA property you name
SalesforceContacts or leads, or one campaignA field you name
MixpanelA cohort's profiles, by Mixpanel's cohort sync (no keys) or a service accountA property you name
AmplitudeA cohort's users, by Amplitude's cohort sync (no keys) or an API keyA property you name
WebhookWhatever your tool sendsA trait you name, or opt_in

Shopify, HubSpot and Salesforce connect with one click: choose the tool, fill in any settings (the store address for Shopify), press Connect with …, and approve read access in the tool. TextKit keeps the access encrypted, refreshes it on each sync, and revokes it when you disconnect. Paste a credential instead takes a private app token or connected app as before.

Pull tools sync every 6 hours and on Sync now. People who leave the source leave the list; they stay contacts. An opt-in is recorded only when the tool records SMS consent, and an unsubscribe there is recorded as an opt-out here, as with any import.

Mixpanel and Amplitude cohort sync

The default way in, with no keys: Connect Mixpanel (or Amplitude) gives you a URL and a password. In Mixpanel, open Data → Destinations → Add Destination → Custom Webhook, paste the URL, add Phone (and your consent property) under Properties To Export, fill HTTP BasicAuth (any username, the password), then choose the cohort to sync. Mixpanel sends the cohort every 30 minutes, with people who join and leave (a paid Mixpanel plan). Build the cohort on your opt-in event (e.g. SMS Opt In) and name it as the Opt-in event: everyone in it is recorded as opted in, with that event as the source. Profiles need a phone number ($phone). Use a service account instead syncs every 6 hours with a service account.

Push to the ingest URL

Webhook, Mixpanel and Amplitude integrations get an ingest URL and a token (shown once). Send the token as Authorization: Bearer <token> or as the password of Basic auth. The URL accepts Segment and RudderStack identify calls (so Hightouch and Census too), Mixpanel's cohort sync webhook, Amplitude's cohort webhook, and this:

bash
curl -X POST https://api.jointextkit.com/v1/integrations/int_a1B2c3D4e5F6/ingest \
  -H "Authorization: Bearer $INGEST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"contacts": [{"phone_number": "+14155550132", "first_name": "Ada", "external_id": "crm-42"}]}'

Send "action": "remove" to take people out of the integration's list. Up to 1,000 people per request. Every reply has "status": "success" or "failure" (with error), and a Mixpanel batch gets its action back.

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.jointextkit.com/v1/segments \
  -H "Authorization: Bearer $TEXTKIT_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, or a window back from now (30d, 12h)
last_reply_atafter, before, exists, not_existswhen they last texted you, on any of your numbers
last_message_atsamethe last text either way
event (+ key = event name)samethe last time a tracked event for their number arrived (newest 20,000 events)
batch (+ key = batch id)is, is_notincluded, sent, failed, skipped, or replied (sent, then texted the batch's number after it was created)

For the time rules, after means at or after the time, before means it happened and the last time was before it, and not_exists means never. A window like 7d is read each time the segment is used, so "replied in the last 7 days" stays the last 7 days.

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). POST /v1/segments/preview takes match and rules without saving them and adds the first limit matching contacts:

bash
curl -X POST https://api.jointextkit.com/v1/segments/preview \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rules": [
        {"field": "batch", "key": "bc_8fK2mQ9xR4tW", "op": "is", "value": "sent"},
        {"field": "batch", "key": "bc_8fK2mQ9xR4tW", "op": "is_not", "value": "replied"}
      ], "limit": 10}'

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, their topic preferences and, when a rule asks, your conversations, the named batch, and your recent tracked events; 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.jointextkit.com/v1/contacts/+14155550132/topics/tp_marketing \
  -H "Authorization: Bearer $TEXTKIT_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.jointextkit.com/v1/templates \
  -H "Authorization: Bearer $TEXTKIT_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, credit_history (your credit ledger) and audit_log (the audit log, with its own filters), with from/to, status, failure_code, 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.jointextkit.com/v1/exports/deliveries?status=failed&from=2026-09-01" \
  -H "Authorization: Bearer $TEXTKIT_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.