# Messages

Source: https://jointextkit.com/docs/messages
Index: https://jointextkit.com/llms.txt

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

| Parameter | Type | Notes |
| --- | --- | --- |
| `from` | string | A number you own (E.164), or a sender pool id (`pool_...`): TextKit picks a member, the same one per recipient. See [Numbers](/docs/numbers). Optional when there is only one number it could be: see [Default sender](#default-sender). |
| `to` | string | Destination number (E.164, US/Canada). |
| `body` | string | Up to 1600 characters. |
| `customer_id` | string | Optional. Attribute the message to one of your [customers](/docs/customers). Defaults to the customer `from` is assigned to. |
| `category` | string | Optional. `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](/docs/compliance-checks#which-messages-are-marketing)). |
| `tags` | string[] | 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](/docs/webhooks), in [deliverability by tag](/docs/deliverability), and filterable with `GET /v1/messages?tag=spring-sale`. |
| `shorten_links` | boolean | Optional. Rewrite every `http(s)` link in `body` to a short link on your own link domain, with clicks tracked. See [Link shortening](#link-shortening). |
| `consent` | object | Optional. 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](/docs/opt-out) 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](/docs/sandbox#simulated-or-real).

### 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](/docs/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.

| Filter | Notes |
| --- | --- |
| `status` | `queued`, `sent`, `delivered`, `failed`, or `received`. |
| `direction` | `outbound` or `inbound`. |
| `failure_code` | Messages that failed for one reason: a [failure code](/docs/errors#delivery-failures) such as `carrier_filtered` or `invalid_number`. An unknown code is a 400. |
| `to` / `from` | Exact number (E.164). |
| `number` | Either side: messages to or from this number. |
| `created_after` | Inclusive. ISO timestamp or epoch ms. |
| `created_before` | Exclusive. ISO timestamp or epoch ms. |
| `customer_id` | One [customer](/docs/customers)'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"
}
```

| Field | Notes |
| --- | --- |
| `simulated` | `true` 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](/docs/sandbox#simulated-or-real). |
| `schedule_id` | Present when the message is an occurrence of a [recurring schedule](/docs/schedules). `GET /v1/messages?schedule_id=sch_...` lists a schedule's messages. |
| `tags` | The labels you sent it with, lowercased. Present only when set. |
| `timeline` | Every 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](#automatic-retries)). Inbound messages have one step, `received`. The gaps are real latencies. |
| `segments` | How many parts the body is split into on the handset. |
| `encoding` | `gsm7` (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`. |
| `price` | What 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](/docs/pricing)). 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_carrier` | The recipient's mobile carrier when a recent [lookup](/docs/lookup) of the number is cached; otherwise `null`. Sending never runs a paid lookup. |
| `category` | `marketing`, `transactional` or `otp`, when you sent one or the message was classified as marketing ([which messages are marketing](/docs/compliance-checks#which-messages-are-marketing)). |
| `deferred` | Present when the message was held for quiet hours and sent when the window opened: `{ "reason": "quiet_hours", "held_at": "..." }`. |
| `links` | Links 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](#link-shortening). |
| `failure` | `null` 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](/docs/errors#delivery-failures). |
| `failure_reason` | The carrier's raw text for a failed send. Kept for compatibility; `failure` is the readable version. |
| `redacted_at` | Present once the message was [redacted](#delete-or-redact-a-message): `body` is empty and `media` is gone. |

## Link shortening

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:

| Type | Name | Value |
| --- | --- | --- |
| `CNAME` | `links.example.com` | `links.jointextkit.com` |
| `TXT` | `_textkit-links.links.example.com` | `textkit-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](/docs/compliance-checks#links) 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`](/docs/webhooks) 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.

| | Delete | Redact |
| --- | --- | --- |
| The message | Gone from the API, the console, threads and exports | Kept: status, timeline, segments, price, failure. `body` empty, `media` gone |
| Its events | Deleted | Kept, with `body` empty and `media` gone |
| Pending webhook retries | Canceled | Sent without the text |
| Request logs that carried it | Request and response bodies removed (the row stays) | Same |
| Conversation preview | Moves to the previous message; a thread with no messages left goes | Emptied if it was this message |
| Carrier receipt text (it quotes the start of the message) | Gone with the message | Cut 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](/docs/audit-logs), 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):

| `reason` | Why |
| --- | --- |
| `not_finished` | Still queued or waiting for its delivery report. To stop a scheduled message, cancel it: `DELETE /v1/scheduled_messages/{id}`. |
| `blocked` | A blocked message is the record of a send we refused. |
| `consent_evidence` | The 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_review` | Our 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](/docs/compliance-checks#review-of-new-kinds-of-message)
we reviewed. [Data Retention](/data-retention) lists what is kept and for how
long.
