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.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" } }'{
"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, or marked yes in an opted-out column
(
opted_out,unsubscribed,do_not_text,dnc), is imported and recorded as an opt-out. Acampaign_idcolumn (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:
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_..."}).
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:
| Tool | What syncs | Consent |
|---|---|---|
| Shopify | Customers, or a customer search like tag:vip; abandoned checkouts, orders and shipments for cart reminders and order texts | Shopify's SMS marketing state |
| HubSpot | Contacts, or one list | A property you name |
| Salesforce | Contacts or leads, or one campaign | A field you name |
| Mixpanel | A cohort's profiles, by Mixpanel's cohort sync (no keys) or a service account | A property you name |
| Amplitude | A cohort's users, by Amplitude's cohort sync (no keys) or an API key | A property you name |
| Webhook | Whatever your tool sends | A 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:
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.
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"}
]}'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, or a window back from now (30d, 12h) |
last_reply_at | after, before, exists, not_exists | when they last texted you, on any of your numbers |
last_message_at | same | the last text either way |
event (+ key = event name) | same | the last time a tracked event for their number arrived (newest 20,000 events) |
batch (+ key = batch id) | is, is_not | included, 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:
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.
# 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_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.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 '.
curl "https://api.jointextkit.com/v1/exports/deliveries?status=failed&from=2026-09-01" \
-H "Authorization: Bearer $TEXTKIT_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.