Messages

Send a message

POST /v1/messages

bash
curl -X POST https://api.jointextkit.com/v1/messages \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+15005550006", "body": "Hello" }'
ParameterTypeNotes
fromstringA number you own (E.164), or a sender pool id (pool_...): TextKit picks a member, the same one per recipient. See Numbers. Optional when there is only one number it could be: see Default sender.
tostringDestination number (E.164, US/Canada).
bodystringUp to 1600 characters.
customer_idstringOptional. Attribute the message to one of your customers. Defaults to the customer from is assigned to.
categorystringOptional. marketing, transactional or otp. It can make a message stricter, never looser: the sending number's registration and the wording decide (which messages are marketing).
tagsstring[]Optional. Up to 10 labels of your own, such as a campaign or a flow (["spring-sale"]): letters, digits, space _ . : -, up to 32 characters each, lowercased. On the message, on its webhook events, in deliverability by tag, and filterable with GET /v1/messages?tag=spring-sale.
shorten_linksbooleanOptional. Rewrite every http(s) link in body to a short link on your own link domain, with clicks tracked. See Link shortening.
consentobjectOptional. How the recipient agreed to your messages: { "source": "checkout", "collected_at": "2026-10-01T15:04:05Z" } (source is one of web_form, keyword, paper, checkout, verbal; add page_url for a web form). A recurring non-marketing message (a receipt, an alert, a reminder) from a number linked to a registration needs an opt-in on file for that registration; when none is, this records it and the message goes. It never counts for marketing: record those with the consent API and full proof. Not with scheduled_at.

Returns 201 with a Message object. status starts at sent (or queued) and progresses via events.

Default sender

Leave from out and the message is sent from your account's one number for that key's mode: your sandbox number with a test key, your live sending number with a live key. So the shortest send is { "to": "...", "body": "..." }.

If the account has no number of that mode, or more than one, there is nothing safe to guess and the request is refused with 400 invalid_request (param: "from"). The message lists the numbers to choose from and the error carries them as numbers. Once you own a second live number, send from explicitly.

Test keys simulate

With a test key the message is simulated: it reaches delivered, but no text is sent, unless to is your own verified phone. When a simulated send is addressed to a real-looking number, the response adds a notice saying so, and every test-key response has an X-TextKit-Simulated header. See Sandbox.

Scheduling

Add scheduled_at (a future ISO timestamp or epoch ms, up to 30 days out) to queue the send. The response is a scheduled_message object; see Scheduled.

Automatic retries

If the carrier has a temporary problem (it is overloaded, throttling, or unreachable), we do not fail your send. The response is 202 with the message queued, and we retry it for you after 30 seconds, 2 minutes and 10 minutes. attempts counts the tries so far and next_attempt_at says when the next one runs, and the message's timeline records every attempt (sent_to_carrier and retry_scheduled with an attempt number). You get message.sent when the carrier accepts it, or message.failed if every attempt fails. You pay only for messages the carrier accepts.

A message is never sent twice. We only retry when we know the carrier did not take the message. If the carrier stops answering after we handed it the message, we cannot know, so the send fails with failure_reason: "carrier_timeout" and you decide whether to send again. A permanent rejection (a bad number, say) fails straight away with 502 carrier_error, as before.

Idempotency

Pass an Idempotency-Key header to make retries safe: the same key + same payload replays the original response (with an Idempotent-Replayed: true header); the same key with a different payload returns 409 idempotency_conflict. Keys last 24 hours, and test and live keys keep theirs apart: a test request never gets a live request's response. Only successful responses are kept: if a request fails, the key is released, so retrying with the same key runs it again. POST /v1/verify and POST /v1/numbers accept the header too. If you can't set headers, send the key as an idempotency_key field in the JSON body instead; the header wins when both are sent. With scheduled_at, the schedule time is part of the payload, so a retried scheduling request never queues a second send.

With the SDKs

Both SDKs add an idempotency key to every send automatically, so their built-in retries can never double-send.

js
import { TextKit } from 'jointextkit';
const sms = new TextKit(process.env.TEXTKIT_API_KEY);

const message = await sms.messages.send({
  to: '+15005550006',
  body: 'Hello',
});
python
from jointextkit import TextKit

client = TextKit()  # reads TEXTKIT_API_KEY

message = client.messages.send(to="+15005550006", text="Hello")
# from_ is optional while your account has one number

Retrieve a message

GET /v1/messages/{id}; the id is the msg_... from the send response.

List messages

GET /v1/messages?limit=25&cursor=...&status=failed&created_after=2026-10-01T00:00:00Z

Newest first. Responses are { "data": [...], "has_more": bool, "next_cursor": "..." }; pass next_cursor back as cursor with the same filters for the next page.

FilterNotes
statusqueued, sent, delivered, failed, or received.
directionoutbound or inbound.
failure_codeMessages that failed for one reason: a failure code such as carrier_filtered or invalid_number. An unknown code is a 400.
to / fromExact number (E.164).
numberEither side: messages to or from this number.
created_afterInclusive. ISO timestamp or epoch ms.
created_beforeExclusive. ISO timestamp or epoch ms.
customer_idOne customer's messages.

Filters combine. Pages come back full: a page shorter than limit with has_more: false is the end. A very sparse filter over a long history can return a short page with has_more: true (one request reads at most 2,000 messages); keep following next_cursor. Narrow with a date range to make those requests fast. The SDKs page for you: sms.messages.listAll() (Node, an async iterator) and client.messages.list_all() (Python, a generator).

The Message object

json
{
  "id": "msg_a1B2c3D4e5F6g7H8",
  "object": "message",
  "to": "+15005550007",
  "from": "+15005550100",
  "body": "Your order shipped",
  "direction": "outbound",
  "status": "failed",
  "test": true,
  "simulated": true,
  "created_at": "2026-10-01T17:02:11.204Z",
  "timeline": [
    { "status": "accepted", "at": "2026-10-01T17:02:11.204Z" },
    { "status": "validated", "at": "2026-10-01T17:02:11.231Z" },
    { "status": "queued", "at": "2026-10-01T17:02:11.248Z" },
    { "status": "sent_to_carrier", "at": "2026-10-01T17:02:11.249Z" },
    { "status": "carrier_accepted", "at": "2026-10-01T17:02:11.412Z" },
    { "status": "failed", "at": "2026-10-01T17:02:13.020Z" }
  ],
  "segments": 1,
  "encoding": "gsm7",
  "price": {
    "total": 0,
    "currency": "usd",
    "breakdown": [{ "label": "Sandbox message (test mode is never billed)", "amount": 0 }]
  },
  "destination_carrier": "Sandbox Wireless",
  "failure": {
    "code": "carrier_filtered",
    "title": "Filtered as spam",
    "explanation": "The recipient's mobile carrier filtered this message as spam or unwanted traffic, so it never reached the phone.",
    "action": "Recommended: identify your business in the first words, ...",
    "carrier_code": null
  },
  "failure_reason": "stat:UNDELIV err:000 (sandbox) message filtered as spam by the destination carrier"
}
FieldNotes
simulatedtrue when nothing was handed to a carrier: every test-key message except one sent to your own verified phone. Always false on live messages. A simulated message still reaches delivered; this field is how you tell. See Sandbox.
schedule_idPresent when the message is an occurrence of a recurring schedule. GET /v1/messages?schedule_id=sch_... lists a schedule's messages.
tagsThe labels you sent it with, lowercased. Present only when set.
timelineEvery step, oldest first: accepted (the API got the request; for a scheduled send, when you scheduled it), validated (every check passed), queued, sent_to_carrier, carrier_accepted, then delivered or failed from the delivery receipt. Steps from sent_to_carrier on carry the carrier attempt they belong to; an attempt that failed temporarily adds retry_scheduled and the next attempt follows (see automatic retries). Inbound messages have one step, received. The gaps are real latencies.
segmentsHow many parts the body is split into on the handset.
encodinggsm7 (160 characters in one segment, 153 per segment when split) or ucs2 (70, then 67). One character outside the GSM alphabet (an emoji, a curly quote) switches the whole message to ucs2. Some symbols (€ [ ] { } ~ ^) count as two characters in gsm7.
priceWhat this message costs, in USD. Outbound SMS is $0.0032 per segment, plus the recipient's carrier fee per segment, at cost, on its own line (carrier fees). Messages that cost nothing say why: sandbox, free-tier allowance, failed before the carrier accepted it (including a retried send whose every attempt failed; any spend reserved for it is released), inbound, automatic replies. null on messages sent before prices were recorded.
destination_carrierThe recipient's mobile carrier when a recent lookup of the number is cached; otherwise null. Sending never runs a paid lookup.
categorymarketing, transactional or otp, when you sent one or the message was classified as marketing (which messages are marketing).
deferredPresent when the message was held for quiet hours and sent when the window opened: { "reason": "quiet_hours", "held_at": "..." }.
linksLinks shortened with shorten_links: url (as you wrote it), short_url, clicks, first_clicked_at, last_clicked_at. On the send response and GET /v1/messages/{id}, not on list pages. See Link shortening.
failurenull unless status is failed or blocked. A stable code, a title, a plain explanation, a recommended action, and the carrier's raw carrier_code when it sent one. Codes are listed in Errors.
failure_reasonThe carrier's raw text for a failed send. Kept for compatibility; failure is the readable version.
redacted_atPresent once the message was redacted: body is empty and media is gone.

Short links go on your own domain, like links.example.com. Carriers filter shared shorteners (bit.ly and the like), and a short domain every TextKit account used would be one. So there is no shared domain to fall back to.

1. Add a subdomain you own.

bash
curl -X POST https://api.jointextkit.com/v1/link-domains \
  -H "Authorization: Bearer ssms_sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "domain": "links.example.com" }'

The response lists two DNS records:

TypeNameValue
CNAMElinks.example.comlinks.jointextkit.com
TXT_textkit-links.links.example.comtextkit-verify=... (in dns_records)

Use a subdomain, not the bare domain. If your DNS host can proxy records, turn the proxy off for the CNAME.

2. Verify. POST /v1/link-domains/{id}/verify checks both records. When it finds them, the domain is turned on at once: status becomes active. If not, verification.problem says what is missing. The certificate can take a minute or two after that.

3. Send with shorten_links.

json
{ "to": "+15005550006", "body": "Your order shipped: https://example.com/orders/123?ref=sms", "shorten_links": true }

The message goes out as Your order shipped: https://links.example.com/Ab3dEf9h.

  • Every http(s) link is shortened; the same link twice gets one short link. Bare domains (example.com) and links already on your link domain are left as they are. Up to 10 links per message.
  • Every check runs on the links as you wrote them. A link must still be on a domain your registration allows.
  • segments, price and body are for the shortened text.
  • No active link domain: 400 link_domain_required, and nothing is sent.
  • Works with scheduled_at: links are shortened when the message is sent.

Clicks. Opening a short link sends the person to the original link (302) and counts a click. GET /v1/messages/{id} shows each link's clicks, first_clicked_at and last_clicked_at, and each click fires a message.link_clicked event. Messaging apps that fetch a link to draw its preview are not counted as clicks. We record the time and the kind of device (mobile, desktop), never the person's IP address. Short links last as long as the message: when it is deleted, so are they. Deleting the link domain (DELETE /v1/link-domains/{id}) stops its short links from opening.

You can add up to 3 link domains. Sends use the one activated most recently.

Statuses

queued → sent → delivered (or failed). Inbound messages have direction inbound and status received.

Carriers report delivery for most messages within minutes. Some never report at all. If a sent message has no delivery report 72 hours after the carrier accepted it, it gets receipt_status: "missing". Its status stays sent: we know the carrier accepted it, and we will not guess whether it arrived. No event fires. A late report still updates the message and clears the flag.

Delete or redact a message

For a privacy request, or text you should never have kept. Both work on finished messages only: delivered, failed, received, or sent with receipt_status: "missing". Both need the messages:send scope, and a key changes only its own mode's messages.

DELETE /v1/messages/{id} removes the message. Answers { "id": "msg_...", "object": "message", "deleted": true }.

POST /v1/messages/{id}/redact keeps the message and empties its text. Answers the message, with body: "" and redacted_at. Safe to repeat.

DeleteRedact
The messageGone from the API, the console, threads and exportsKept: status, timeline, segments, price, failure. body empty, media gone
Its eventsDeletedKept, with body empty and media gone
Pending webhook retriesCanceledSent without the text
Request logs that carried itRequest and response bodies removed (the row stays)Same
Conversation previewMoves to the previous message; a thread with no messages left goesEmptied if it was this message
Carrier receipt text (it quotes the start of the message)Gone with the messageCut from the receipt and failure_reason, now and on any late receipt

Always kept: opt-out and consent records, usage, billing and delivery statistics (counts), and the audit log, which records message.deleted or message.redacted with the other number's last four digits, never the text. A batch's results keep a deleted message's outcome and price. Text you saved yourself (a template, a schedule, a broadcast, an automation) is yours to edit or delete.

Refused with 409 invalid_state (reason says which):

reasonWhy
not_finishedStill queued or waiting for its delivery report. To stop a scheduled message, cancel it: DELETE /v1/scheduled_messages/{id}.
blockedA blocked message is the record of a send we refused.
consent_evidenceThe message is the proof of an opt-out or opt-in (a STOP, a START, a YES). It is kept with the consent record.
under_reviewOur team is reviewing the registration it was sent under. Contact support.

Our own review records are separate and are not changed: messages the content screen refused (up to 320 characters) and the first message of each new kind of message we reviewed. Data Retention lists what is kept and for how long.