# TextKit · SMS for Developers Programmable SMS and phone numbers. Base URL: https://api.jointextkit.com/v1 Authentication: Authorization: Bearer ssms_sk_test_... (or ssms_sk_live_...) Console (free sandbox keys): https://jointextkit.com/console OpenAPI: https://jointextkit.com/api/v1/openapi.yaml (also .json) --- # Quickstart Send your first SMS in under five minutes. No card, no sales call: a test key works instantly against the sandbox. ## Choose your path The console asks what you are here to do and shows only the steps that goal needs. The same four paths, in the docs: - **Send SMS from your app.** Stay on this page: get a key, send a test, then [buy a number](/docs/numbers), [register your use case](/docs/compliance) and go live. You never need to import contacts. - **Send a broadcast to customers.** Upload your list to [Contacts](/docs/contacts), then compose, preview, check and send a [broadcast](/docs/broadcasts). - **Build an automated SMS flow.** Emit events from your code and let a flow send the messages, with waits and conditions, under [Automations in the console](/console/automations). - **Move from another provider.** Bring numbers, sender pools and webhooks for app messaging, or numbers, contacts and templates for marketing: [migration guides](/migrate). A marketing send is a *broadcast*. "Campaign" here only ever means the use case you register with carriers, which lives under [Compliance](/docs/compliance). Using Node.js, Python, Next.js, Django, Laravel or another stack? The [quickstarts by language](/docs/quickstarts) show the same steps in it. ## 1. Get a key Create a free account at [the console](/console). A sandbox tenant is provisioned automatically with a test key (`ssms_sk_test_...`) and a sandbox number. Copy it from Home. ## 2. Send a message ```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 from TextKit" }' ``` That is the whole request. `from` is optional here: a test key sends from your sandbox number (shown in the console). `+15005550006` is a magic sandbox number that simulates successful delivery. Test keys simulate. The message below ends up `delivered`, but no text is sent to any phone: `"simulated": true` says so. The one exception is your own verified phone. Verify it (sign-up offers this, or [Settings](/console/settings) later), put it in `to`, and a test key hands that text to a carrier for real; the response then says `"simulated": false`. See [Sandbox](/docs/sandbox#simulated-or-real). ## 3. Read the response ```json { "id": "msg_a1B2c3D4e5F6g7H8", "object": "message", "to": "+15005550006", "from": "+15005550100", "body": "Hello from TextKit", "direction": "outbound", "status": "delivered", "test": true, "simulated": true, "created_at": "2026-08-06T16:20:00.000Z" } ``` Fetch it back anytime with `GET /v1/messages/{id}`, and watch the delivery lifecycle in `GET /v1/events` (`message.sent`, then `message.delivered`). The message's `timeline` shows each step with its timestamp. ## 4. Simulate a reply ```bash curl -X POST https://api.jointextkit.com/v1/test/inbound \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+14155550132", "body": "Hey, got your message!" }' ``` The inbound message lands in `GET /v1/messages` and emits a `message.received` event, exactly what a real inbound SMS will do in live mode. ## 5. Receive events (webhooks) Instead of polling, add an endpoint URL under [Webhooks in the console](/console/webhooks) and every event is POSTed to you, signed, with automatic retries. Details in the [webhooks docs](/docs/webhooks). ## 6. Go live When you're ready to send real SMS from real numbers, add a number and register your business from the [console](/console). We verify your business (someone authorized to act for it signs for it) and file your registration with the carriers. Until the carriers approve it, your number can text only your own verified numbers; after that, it can text anyone who opted in. See [going live](/docs/compliance). --- # Authentication Every request is authenticated with an API key in the `Authorization` header: ```bash Authorization: Bearer ssms_sk_test_... ``` ## Test and live keys | Prefix | Mode | Behavior | | --- | --- | --- | | `ssms_sk_test_` | Sandbox | Instant and free. Messages are simulated (`"simulated": true`): no text is sent, except to your own verified phone. See [Sandbox](/docs/sandbox#simulated-or-real). | | `ssms_sk_live_` | Live | Real numbers and real delivery. Mintable once your account has live access. | Keys issued with an older prefix (`dsms_sk_*`, `resms_sk_*`) are still accepted and will never be revoked for their prefix; new keys mint as `ssms_sk_`. Keys never expire, but you can roll or revoke them anytime from the [console](/console/keys). Rolling revokes the old key immediately and mints a replacement. ## Storage We look keys up by a keyed hash (HMAC-SHA256 under a secret held on our servers). Each active key is also kept encrypted (AES-256-GCM, under a separate key held on our servers) so an admin can view and copy it again from the console. Every view is in the [audit log](/console/audit-log). Revoking or rolling a key deletes its encrypted copy. Keys made before October 27, 2026 can't be viewed again: roll one to get a key you can. ## Scopes A key has full access unless you restrict it. In [Console → API keys](/console/keys), choose **Restricted** and tick the scopes the key needs; a key that leaks can then only do what its job required. Rolling a key keeps its scopes. | Scope | Allows | | --- | --- | | `messages:send` | `POST /v1/messages`, `POST /v1/test/inbound` | | `messages:read` | `GET /v1/messages`, `GET /v1/messages/{id}`, `GET /v1/deliverability` | | `numbers:read` | `GET /v1/numbers`, `GET /v1/numbers/available` | | `numbers:write` | `POST /v1/numbers`, `PATCH /v1/numbers/{id}`, `DELETE /v1/numbers/{id}` | | `verify` | `/v1/verify`, `/v1/verify/check`, `/v1/verify/{id}` | | `consent` | `/v1/consent` and everything under it | | `lookup` | `GET /v1/lookup/{phone}` | | `webhooks` | `GET /v1/events`, `GET /v1/events/wait`, `POST /v1/events/{id}/replay`, `GET /v1/webhooks`, `GET /v1/webhooks/deliveries` | | `webhooks:write` | `POST /v1/webhooks`, `GET`/`PATCH`/`DELETE /v1/webhooks/{id}`, `POST /v1/webhooks/{id}/rotate_secret`, `/test` and `/replay` (creating an endpoint or rotating its secret returns the secret) | | `customers` | `/v1/customers` and everything under it | | `contacts` | `/v1/contacts`, `/v1/lists`, `/v1/segments`, `/v1/topics` and everything under them | | `registrations` | `/v1/registrations` and everything under it | | `billing` | `GET /v1/balance`, `GET /v1/usage`, `GET`/`PATCH /v1/spend-limit` | | `automations` | `POST /v1/track`, `/v1/automations` and everything under it | | `audit_logs:read` | `GET /v1/audit-logs`, `GET /v1/exports/audit_log` | | `links` | `/v1/link-domains` and everything under it ([link shortening](/docs/messages#link-shortening); sending with `shorten_links` needs `messages:send`) | | `keys` | `GET`/`POST /v1/api_keys`, `DELETE /v1/api_keys/{id}` | A restricted key calling outside its scopes gets: ```json { "error": { "code": "insufficient_scope", "message": "This API key is missing the `messages:send` scope. Use a key with that scope, or mint one in the console (API keys).", "required_scope": "messages:send" } } ``` Keys minted before scopes existed, and keys minted without choosing any, keep full access. ## Keys for the apps you build A key can create keys, so an agent building an app (in Replit, Claude Code or Cursor) can give the app its own key to keep as a secret: ```bash curl https://api.jointextkit.com/v1/api_keys \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Booking app", "scopes": ["messages:send", "verify"]}' ``` The response has the `secret`, once. A key only creates keys of its own mode (a test key makes test keys) and never a broader one: a key with limited scopes can only create keys with scopes inside its own. Every new key emails the account's owner and admins and is in the audit log. `GET /v1/api_keys` lists the keys of your mode; `DELETE /v1/api_keys/{id}` revokes one. 20 new keys an hour. ## Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Missing, malformed, revoked, or unknown key. | | 403 | `key_paused` | We paused this live key because it suddenly made far more requests than usual from a country it had not been used from. An admin can resume it, or roll it, under **API keys**. See [unusual key activity](/docs/security#unusual-api-key-activity). | | 403 | `live_access_required` | Live key used before live access was granted, a live-only endpoint hit with a test key, or a test key reaching data the whole account shares (contacts, consent, templates, customers, registrations, pools, the spend limit, the audit log) on an account with live access. | | 403 | `insufficient_scope` | A restricted key called an endpoint outside its scopes; `required_scope` names the one it needs. | | 403 | `tenant_suspended` | The account is suspended. | | 403 | `account_closed` | The account is closed. Every key of a closed account is revoked too. | | 403 | `email_unconfirmed` | The account's email isn't confirmed yet, so it can't create keys or webhooks or connect integrations. Confirm it in the console with the emailed code. | --- # Sandbox & test numbers Test keys (`ssms_sk_test_`) run against a simulated environment: no charges, and no text is sent to any phone, with one exception, your own verified phone (below). Every endpoint works, so you can build your whole integration, including webhooks, before going live. ## Simulated or real A simulated message looks exactly like a real one. Its `status` goes to `sent` and then `delivered`, it has a `destination_carrier` (`Sandbox Wireless`), and the same events and webhooks fire. None of that means a handset received anything. Three things tell you which it was: | Where | Simulated | Real | | --- | --- | --- | | `simulated` on the message, and on `message.sent`, `message.delivered`, `message.failed`, `message.received`, `message.opted_out`, `message.opted_in` and `message.help_requested` event payloads | `true` | `false` | | `X-TextKit-Simulated` header on the response to `POST /v1/messages` (test keys) | `true` | `false` | | `notice` on the response to `POST /v1/messages` | Present when `to` is not a `+1 500 555` test number, e.g. "Simulated: nothing was sent to +14155550132. Test keys only deliver to your verified phone. Verify it at https://jointextkit.com/console/settings or use a live key." | Absent | Every live message has `"simulated": false`. With a test key, only a send to your own verified phone can be real. A test-key send to any other real number, including one of your verified test recipients, is simulated: nothing arrives. The console shows the same thing. It lists live and test messages together; a test message is tagged `Test`, a simulated one `Test · simulated`, and it says "Nothing was sent to a phone" when you open it. API Keys → **Test credentials** lists your sandbox number and every magic number below. ## Your sandbox number Signup provisions a sandbox number in the reserved `+1 500-555-XXXX` range. It's the `from` for outbound tests and the `to` for simulated inbound. You can "purchase" more from `GET /v1/numbers/available` + `POST /v1/numbers`; the whole numbers API works in the sandbox. ## Your own phone Sign-up offers to verify your cell as its last step (optional, and you can do it later under [Settings](/console/settings)): we text you a code. Once it's verified, a test key delivers to **that one number for real**, so your first API call makes your phone buzz. Every other destination stays simulated. Real sandbox texts are capped at 10 a day and end with `- via TextKit sandbox`. The response is a normal message with `"test": true`, `"simulated": false` and an `X-Sandbox-Real-Delivery: true` header. Your phone also counts as your first verified recipient when you go live on the free tier. Real delivery to your phone needs a verification sender on our side. If none is available the send is simulated like any other, and says so: `"simulated": true` with a `notice` that real delivery is unavailable right now. Nothing is ever reported as real that was not handed to a carrier. ## Magic destination numbers Send **to** these numbers to trigger fixed behaviors: | Number | Behavior | | --- | --- | | `+15005550006` | Delivered: `message.sent` then `message.delivered`; the message is `delivered` by the time the send returns. Any other number behaves the same. | | `+15005550013` | Delayed delivery: status `sent`, then really delivered at least 5 seconds later. Poll `GET /v1/messages/{id}` (it settles as soon as the delay has passed) or wait for the `message.delivered` webhook (within about a minute). Use it to test code that waits on delivery. | | `+15005550001` | Stuck: status stays `queued` forever, no delivery event. | | `+15005550002` | Failed, no reason given: `failure.code` `unknown`. | | `+15005550007` | Failed: filtered as spam, `failure.code` `carrier_filtered`. | | `+15005550008` | Failed: refused by the carrier, `failure.code` `carrier_rejected`. | | `+15005550009` | Failed: number not in service, `failure.code` `invalid_number`. | | `+15005550010` | Failed: a landline, `failure.code` `landline` (and `GET /v1/lookup` says `landline`). | | `+15005550014` | Failed: phone unreachable, `failure.code` `unreachable`. | | `+15005550011` | Opted out: `403 recipient_opted_out`, exactly as for a recipient who replied STOP. Nothing is written to your opt-out list; the refusal is stored as a `blocked` message. | | `+15005550012` | Rate limited: `429 rate_limited` with a `Retry-After` header. The refusal is stored as a `blocked` message. | | `+15005550016` | No opt-in on file: a marketing message (`"category": "marketing"`, or any broadcast or batch) answers `403 consent_required`. The other 500-555 numbers count as opted in, so test broadcasts to them deliver. See [opt-in on file](/docs/compliance-checks#opt-in-on-file). | | `+15005550021` to `+15005550027` | Verify only: a code to one of these answers `403 verification_blocked` with one Verify Shield `reason` each (`destination_cooldown`, `destination_velocity`, `tenant_velocity`, `ip_velocity`, `voip_not_supported`, `unsupported_region`, `toll_free_destination`), like a live key. See [Verify](/docs/verify#shield). | | `+15005550015` | Quiet hours: a recipient for whom it is always night. A marketing message answers `403 quiet_hours`, or `202` with a held scheduled message when you add `"quiet_hours": "defer"`. Any other message answers `202` with a held scheduled message. Only a message that reads as a one-time code delivers. See [quiet hours](/docs/compliance-checks#quiet-hours). | Failed numbers return `201` with status `failed` and emit `message.sent` then `message.failed`; the `failure` object and the event payload are the same ones a real carrier failure produces (see [Errors](/docs/errors#delivery-failures)). Every sandbox message has a full `timeline`, `segments`, `encoding`, a `price` of 0, and `destination_carrier` from the sandbox lookup. ## Simulated inbound `POST /v1/test/inbound` delivers a fake inbound SMS to one of your sandbox numbers through the real pipeline: it appears in `GET /v1/messages` and emits a `message.received` event with `"simulated": true`. (Test keys only; live keys get a 403 `test_mode_only`.) `to` must be a sandbox number. STOP, START, YES and HELP behave as they would for real, so you can rehearse opt-out. Once your account has live access its opt-out list is real, so a simulated message changes it only when `from` is a `+1 500 555` number or one of your verified numbers. From any other number the message is handled as an ordinary one, consent unchanged, and the response carries a `notice` saying so. ## Test and live data Once your account has live access, each key sees only its own mode's data, as in Stripe. A test key lists and reads sandbox messages, events, batches, schedules, scheduled messages, automations, numbers, exports and webhook endpoints; a live key the live ones. Asking for an item of the other mode answers `404 not_found`, with a message saying which key to use. Every event says which it is (`"mode": "test"` or `"live"`), and a [webhook endpoint](/docs/webhooks#test-and-live-endpoints) receives only its own mode's events. Before live access there is no live key, so your test keys see everything on the account, as they always have. A test key never changes anything live: it can't send or schedule from a live number, or change a live schedule, automation, batch, scheduled message or number (403 `forbidden`), and a bulk number action reports `mode_mismatch` for each number of the other mode. Some data belongs to the whole account: contacts, segments and topics, the consent list, templates, customers, registrations, number pools, the spend limit and the audit log. On a sandbox account test keys use it as before. **Once your account has live access, it needs a live key** (403 `live_access_required`). Balance and usage stay readable with either key. The key `textkit login` makes is a test key restricted to what the CLI does: send and read messages, Verify, numbers, lookups and events. ## Sandbox limits A ceiling of 1,000 messages/day per account keeps the sandbox healthy. It's not a product quota. If you legitimately hit it, tell us. --- # 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" }' ``` | 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. --- # Verify Phone verification in two calls. TextKit generates the code, sends it, enforces expiry and attempt limits, and defends against SMS pumping. You never store a code, and you don't need to own a phone number. Codes can also go [by email](#by-email), or [move to email](#when-the-text-doesnt-arrive) when a text doesn't arrive. **Live keys need a live account.** Verify needs no number of your own, but until your account is live a live key answers `403 live_access_required`. An account goes live when the carriers verify its toll-free number, usually in 3 to 7 business days. Build and test with a test key until then: the [sandbox](#sandbox) answers like live, blocks included. ## The whole integration Two calls, no dependency required: ```js const send = (to) => fetch('https://api.jointextkit.com/v1/verify', { method: 'POST', headers: { Authorization: `Bearer ${process.env.TEXTKIT_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to }), }).then((r) => r.json()); const check = (to, code) => fetch('https://api.jointextkit.com/v1/verify/check', { method: 'POST', headers: { Authorization: `Bearer ${process.env.TEXTKIT_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to, code }), }).then((r) => r.json()); ``` Or from the terminal: the CLI ships inside the SDK package, so `npx` needs no install at all: ```bash TEXTKIT_API_KEY=ssms_sk_test_... npx jointextkit verify +14155550132 npx jointextkit verify +14155550132 482193 # check the code ``` Or with the official SDK, which adds retries, typed errors and automatic idempotency keys: ```bash npm install jointextkit ``` ```js import { TextKit } from 'jointextkit'; const sms = new TextKit(process.env.TEXTKIT_API_KEY); await sms.verify.send({ to: '+14155550132' }); const { verified } = await sms.verify.check({ to: '+14155550132', code }); ``` In Python (`pip install jointextkit`, no dependencies): ```python from jointextkit import TextKit client = TextKit() # reads TEXTKIT_API_KEY client.verify.send(to="+14155550132") verified = client.verify.check(to="+14155550132", code=code)["verified"] ``` ## Send a code ```bash curl -X POST https://api.jointextkit.com/v1/verify \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+14155550132" }' ``` ```json { "id": "ver_a1B2c3D4e5F6g7H8", "object": "verification", "channel": "sms", "phone": "+14155550132", "email": null, "status": "pending", "attempts": 0, "attempts_remaining": 5, "max_attempts": 5, "test": true, "created_at": "2026-08-07T12:00:00.000Z", "expires_at": "2026-08-07T12:10:00.000Z", "expires_in": 600, "charged": false } ``` Send an `Idempotency-Key` header (or an `idempotency_key` body field) so that a retried request, after a timeout for example, returns the first verification instead of texting a second code. It works the same way as [on messages](/docs/messages#idempotency). The response comes back as soon as the code is sent; webhooks for it go out right after. Optional `app_name` (24 chars max) puts your product's name in the message. The body is a fixed TextKit template; you can't set the text, which is what keeps verification traffic out of spam filtering. ## You don't need a phone number Verify sends from TextKit's own verification numbers, registered under our 10DLC campaign. You don't buy a number, you don't provision anything, and you pay no monthly number fee; verification is the whole product. The same recipient always gets codes from the same sender, so a second code lands in the thread they already have. If our verification numbers are not available, a live code goes out from one of your own numbers that can already text anyone: a US local number whose [sender status](/docs/numbers#sender-status) is `active`, or a [verified toll-free number](/docs/numbers#toll-free-numbers). A number still being registered is never used, because the carriers would refuse the message. A code sent from a verified toll-free number always names the business its verification names. For a verification like "Acme Holdings Inc d/b/a Acme", `app_name` may be the d/b/a ("Acme"), the legal name or the full name; left out, codes use the d/b/a. Another name is replaced by the verified one, and the response carries a `warning` saying so, so an integration keeps working the day its number is approved. When no number is ready, the request answers **400** `invalid_request` with a message that says so, and nothing is sent or charged. If you'd rather codes came from a number you own, pass it as `from`: ```json { "phone": "+14155550132", "from": "+16155550184" } ``` Platforms verifying for their own customers can pass `customer_id`; it is echoed on the verification and its events and counted in that [customer's usage](/docs/customers). ## Check the code ```bash curl -X POST https://api.jointextkit.com/v1/verify/check \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+14155550132", "code": "482193" }' ``` ```json { "verified": true, "channel": "sms", "phone": "+14155550132", "status": "approved", "attempts": 1, "attempts_remaining": 4, "max_attempts": 5, "charged": true } ``` A wrong code answers `verified: false` with the tries left. Checking a verification that was already approved (a double submit, a retry) answers **409** `verification_already_approved` with its `verification_id` and `approved_at`, and nothing is charged again; `verification_not_found` means no code was sent, or it expired. The phone field in errors is named as you sent it (`phone` or `to`), and a number with a stray character in it (`+12125551234x`) is refused rather than trimmed. That's the whole integration. ## By email Set `channel` to `"email"` and pass `email` instead of `phone`: ```bash curl -X POST https://api.jointextkit.com/v1/verify \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "email", "email": "ada@example.com", "app_name": "Acme" }' ``` Check it with `email` in place of `phone`: ```json { "email": "ada@example.com", "code": "482193" } ``` The email is plain text with no links: the code, your `app_name`, that it expires in 10 minutes, and not to share it. It comes from TextKit, named "Acme via TextKit". You can't change the text. Billing is the same as a text: charged only when the code is checked. Disposable addresses and domains that take no mail are refused with `403 verification_blocked`. If email can't go out right now, the request answers `503 channel_unavailable`, nothing is charged, and a text still works. ## When the text doesn't arrive Move a pending text verification to email. It keeps its id, so it is still one verification and one charge: ```bash curl -X POST https://api.jointextkit.com/v1/verify/ver_a1B2c3D4e5F6g7H8/fallback \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com" }' ``` The email carries a new code, and the texted code stops working. **Check it by `email`**: the code proves the email address, not the phone, so the answer is `{ "verified": true, "channel": "email", "email": "ada@example.com" }` and a check by `phone` answers `409 invalid_state` (no try is used). If your sign-in is keyed on the phone, treat the person as having proved the email address, not the number. The tries carry over (5 in all), and the new code gets a fresh 10 minutes. A verification moves to email once, while it is pending; after that the request answers `409 invalid_state`. The email limits below apply. ## You only pay when it works A verification is billed **only** when a code is actually verified. Wrong codes, expiries, abandoned flows and anything Shield blocks are free; every response tells you plainly with `charged`. ## Rules TextKit enforces for you | | | | --- | --- | | Code lifetime | 10 minutes | | Check attempts | 5, then the code is dead | | Resend cooldown | 60 seconds per number | | Per number | 5 an hour, 10 a day, counted per account | | Per email address | 1 a minute, 5 an hour, 10 a day, counted per account (`ada+1@` and `ada@` are one address; so are Gmail's dotted spellings) | | Per email domain | 30 an hour, 200 a day, counted per account on the registrable domain (`a.acme.com` and `b.acme.com` count together). Large mailbox providers (Gmail, Outlook, iCloud and similar) are counted by address only | | Email codes per account | 100 a day (raised on request), within your daily verification limit; past it, `429 quota_exceeded`. A code that fails to send does not count | | Codes sent per account | Your daily verification limit (2,000 on paid accounts, raised on request), texts and emails together; past it, `429 quota_exceeded` until the next day | Statuses: `pending`, `approved`, `expired`, `max_attempts`, `blocked`. ## Shield SMS pumping is fraud where someone farms revenue-share by triggering verification codes to numbers they control. TextKit blocks it before you're charged: - **US and Canada only.** Country code +1 also covers Jamaica, the Dominican Republic and the Bahamas, the classic pumping destinations. We allowlist real US and Canadian area codes and reject the rest. - **Velocity limits** per destination and per account, and per end user when you pass your user's IP address as `user_ip` (20 codes an hour per IP on your account). Pass it from your sign-up or login form: it stops one person requesting codes for many numbers. Requests come from your server, so without it there is no per-IP limit. - **No VoIP.** Disposable VoIP numbers are rejected. - **By email:** no disposable addresses, the domain must take mail, and the per-address and per-domain limits above. `user_ip` counts texts and emails together. Free accounts can text codes only to numbers they have verified, and email codes only to the account's own email address. Blocked attempts return `403` with code `verification_blocked`, a `reason`, and `charged: false` (velocity blocks add `Retry-After`): | `reason` | What happened | Sandbox number | | --- | --- | --- | | `unsupported_region` | Not a US or Canadian number (or not a valid number) | `+15005550026` | | `destination_cooldown` | A code went to this number in the last minute | `+15005550021` | | `destination_velocity` | 5 codes to this number in an hour, or 10 in a day | `+15005550022` | | `tenant_velocity` | Your account's hourly ceiling (300 codes) | `+15005550023` | | `ip_velocity` | 20 codes an hour for one `user_ip` | `+15005550024` | | `voip_not_supported` | A VoIP number | `+15005550025` | | `toll_free_destination` | A toll-free number (800, 833, 844, 855, 866, 877, 888) | `+15005550027` | | `domain_velocity` | Email only: too many codes to one email domain | | ## Sandbox Test keys never send a real message. Any code you send returns a verification whose code is `111111`, and these codes are deterministic: | Code | Result | | --- | --- | | `111111` | approved | | `000000` | invalid | | `222222` | expired | | `333333` | max attempts | Sandbox has **no resend cooldown**, so you can iterate on a "resend code" button freely. Live enforces 60 seconds per number. To test that path deterministically, send to `+15005550003`; it always returns the cooldown `429` with `retry_after` in the body. The sandbox skips the Shield, so each block has its own number (table above): sending a code to it answers the same `403 verification_blocked`, `reason` and `Retry-After` a live key would get, and records a blocked verification with a `verification.blocked` event. By email, test keys never send anything: any address works, reserved domains like `example.com` included, and the code is `111111`. A fallback keeps `111111`. `cooldown@textkit.test` always returns the cooldown `429`, and a disposable address is refused as in live. ## Retrieve a verification `GET /v1/verify/{id}` returns the object with its current status, attempt count, channel, and whether it was charged. It is the quickest way to debug a sign-in flow: a code that "didn't work" shows here as `pending` with tries used, `expired`, `max_attempts`, `approved` or `blocked`, and `test: true` tells you a test key made it. ```bash curl https://api.jointextkit.com/v1/verify/ver_a1B2c3D4e5F6g7H8 \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" ``` Every `verification.*` [webhook](/docs/webhooks) carries `channel`, and `phone` or `email` (both, after a fallback; a fallback's `verification.sent` has `fallback: true`). --- # Webhooks Add an endpoint URL in the [console](/console/webhooks), or with [`POST /v1/webhooks`](#manage-endpoints-with-the-api), and every event (inbound messages, delivery updates, verification results) is POSTed to it as JSON. That plus an API key is everything you need: send with the API, receive with webhooks. Building the handler on your own machine? [Test locally](#test-locally) needs no tunnel and no public URL. Events are also pollable via [`GET /v1/events`](/docs/messages) if you prefer pull over push. ## Payload Webhook bodies are exactly the event objects from `/v1/events`: ```json { "id": "evt_a1B2c3D4e5F6g7H8", "object": "event", "type": "message.received", "mode": "test", "created_at": "2026-08-13T00:41:00.000Z", "data": { "message_id": "msg_x9Y8z7W6v5U4t3S2", "from": "+14155550132", "to": "+15005550100", "body": "Hey, got your message!", "conversation": "5005550100_4155550132", "simulated": true } } ``` `simulated` is `true` only for a message a test key simulated (`POST /v1/test/inbound`); a real inbound SMS has `false`. Act only on `false` in production. Every event also has `mode`: `"test"` for sandbox traffic (your test keys' sends, including the real text to your own verified phone, and simulated inbound), `"live"` for live traffic. Events about the account itself (credits, spend, registrations, consent, deliverability and compliance alerts) are `live` once the account has live access. ## Test and live endpoints Once your account has live access, an endpoint receives only one mode's events. One made with a test key gets test events, one made with a live key live events; in the console, a live account chooses **Live events** or **Test events** when adding it. A key lists, changes and replays only its own mode's endpoints, so a test key can never point live traffic at a URL of its choosing. Endpoints added before 2026-10-22 count as live. Before live access every endpoint receives every event, as before. ## Event types | Type | Fires when | | --- | --- | | `message.sent` | An outbound message was accepted by the carrier | | `message.delivered` | The carrier confirmed delivery | | `message.failed` | Delivery failed permanently | | `message.received` | An inbound SMS arrived on one of your numbers | | `number.purchased` | A number was added to your account | | `number.released` | A number was released | | `number.sender_updated` | A live number's standing with the carriers changed (`test_only`, `pending`, `active`, `action_needed`). See [Numbers](/docs/numbers#sender-status) | | `verification.sent` | A verification code was sent, by text or email (`channel`; `fallback: true` when a text verification moved to email) | | `verification.approved` | A code was checked successfully (`channel`, `phone` and/or `email`) | | `verification.failed` | A code check failed | | `verification.blocked` | A verification was blocked by fraud protection | | `deliverability.degraded` | Your delivery rate dropped sharply for all traffic, a carrier, or a sending number ([Deliverability](/docs/deliverability)) | | `compliance.flagged` | More than 0.5% of a registration's recipients opted out in 24 hours (at least 200 sent). Nothing is paused yet. `registration_id`, `opt_out_rate`, `sent`, `opt_outs`, `window_hours` ([opt-out rate](/docs/compliance-checks#opt-out-rate)) | | `compliance.paused` | More than 4% opted out (at least 100 sent): marketing from the registration's numbers is paused until our team resumes it. Same fields | | `compliance.resumed` | Our team resumed marketing on the registration. `registration_id`, `paused_at` | | `spend.threshold_reached` | Estimated spend crossed one of your [spend limit](/docs/spend-limits) alert thresholds | | `credits.low`, `credits.depleted`, `credits.topped_up`, `credits.auto_recharge_failed` | Your [credit balance](/docs/credits) ran low, ran out, was topped up, or an auto-recharge was declined | | `automation.run.started`, `automation.run.completed`, `automation.run.failed` | An [automation](/docs/automations) run began, or ended (with `reason`) | | `schedule.occurrence_skipped` | A [recurring schedule](/docs/schedules) did not send an occurrence: `reason` is `recipient_opted_out` or `missed` | | `schedule.paused` | A recurring schedule paused itself (`reason`: `recipient_opted_out`, `repeated_failures` or `sender_unavailable`) | | `message.blocked` | A send was refused by one of our [checks](/docs/compliance-checks) before any carrier saw it. `reason` is the code (`opted_out`, `quiet_hours`, `consent_required`, `reconfirmation_pending`, `reconfirmation_expired`, `sender_not_registered`, `content_blocked`, ...), `message_id` is the blocked message in your log, and `failure` is the readable object. A held message's row is announced twice: once as `reconfirmation_pending` when it is held, and again as `reconfirmation_expired` (same `message_id`) if nobody answers; likewise `held_for_review`, then `review_rejected` or `review_expired` if it is dropped | | `message.deferred` | A message was held for [quiet hours](/docs/compliance-checks#quiet-hours). Carries `scheduled_message_id`, `recipient_local_time`, `timezone` and `send_after`. `message.sent` follows when it goes out | | `message.link_clicked` | Someone opened a [short link](/docs/messages#link-shortening) in your message. Carries `message_id`, `url`, `short_url`, `clicked_at`, `device` (`mobile`, `desktop` or `unknown`), `clicks` (the total so far) and `first_click`. Link previews do not fire it | | `test.ping` | You pressed "Send test" in the console, or called `POST /v1/webhooks/{id}/test` | Events about one message (`message.sent`, `.delivered`, `.failed`, `.received`, `.blocked`) carry its `price`, the same object the message shows, and its `tags` when it was sent with any. So you can book what each message cost, per campaign, without fetching it: ```json "price": { "total": 0.0082, "currency": "usd", "breakdown": [{ "label": "Outbound SMS (1 segment)", "amount": 0.0032 }, { "label": "Carrier fee (Verizon)", "amount": 0.005 }] } ``` Endpoints receive all events by default; pass an `events` array when creating one to filter. ## One endpoint per number Pass `numbers` to send only some numbers' events to an endpoint, the way a number's own inbound URL works elsewhere: ```bash curl https://api.jointextkit.com/v1/webhooks \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://support.example.com/sms","events":["message.received"],"numbers":["+14155550132"]}' ``` Message events match on their `from` or `to`, number events on `phone_number`. Events about no number (batches, credits, registrations) go only to endpoints without a filter. Change it with `PATCH /v1/webhooks/{id}`; `[]` clears it. ## Status callback on one message Add `status_callback` to `POST /v1/messages` and that message's `message.sent`, `message.delivered`, `message.failed` and `message.blocked` events also go to that URL, on top of your endpoints. 1. Create the signing secret once: `POST /v1/webhooks/callback_secret`. It is returned once. Calling it again rotates it; the old one signs for 24 more hours. 2. Send with `"status_callback": "https://example.com/sms/status"`. 3. Verify each request exactly like an endpoint's (below). Until the secret exists, a send with `status_callback` returns `400 callback_secret_required`. Callbacks are logged and retried like an endpoint's: `GET /v1/webhooks/deliveries?endpoint_id=status_callback` lists the attempts. Immediate sends only: for scheduled messages, subscribe an endpoint. ### message.failed ```json { "type": "message.failed", "data": { "message_id": "msg_a1B2c3D4e5F6g7H8", "to": "+15005550009", "code": "undeliverable", "reason": "stat:UNDELIV err:001", "failure": { "code": "invalid_number", "title": "Number not in service", "explanation": "The destination number does not exist or is no longer assigned to a phone.", "action": "Recommended: stop sending to this number and ask the recipient for an up-to-date one. Retrying will not help.", "carrier_code": "001" } } } ``` `failure` is the same object as on the message (see [delivery failures](/docs/errors#delivery-failures)); `code` and `reason` are kept for existing integrations. ## Verify signatures Every request carries a `textkit-signature` header: ``` textkit-signature: t=1755043260,v1=5257a869e7... ``` The event id (`evt_...`, the same across retries) is in `textkit-event-id`. Both are also sent under the header names from before the product was renamed, with identical values, and always will be: `dsms-signature` / `dsms-event-id` and `resms-signature` / `resms-event-id`. A handler that reads either keeps verifying; new handlers should read the `textkit-` pair. `v1` is `HMAC-SHA256(secret, \`${t}.${rawBody}\`)`, the same scheme Stripe uses. Your signing secret (`whsec_...`) is next to the endpoint in the console, by its last 4 characters; admins choose **Reveal** to see and copy it, which the [audit log](/docs/audit-logs) records as `webhook.secret_viewed`. To replace a secret, choose **Rotate secret** on the endpoint in the [console](/console/webhooks) (admins only), or call `POST /v1/webhooks/{id}/rotate_secret`. From the console the old secret stops verifying immediately, and retries of earlier failures are signed with the new secret too. From the API you can keep both working for a while: ```bash curl -X POST https://api.jointextkit.com/v1/webhooks/we_.../rotate_secret \ -H "Authorization: Bearer ssms_sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "expire_previous_in": 86400 }' ``` The response has the new `secret` (shown once) and `previous_secret_expires_at`. Until then every delivery carries two signatures, `t=...,v1=,v1=`, and a receiver holding either secret accepts it. Up to 7 days; 0, the default, retires the old secret now. Anything your receiver rejects is retried on the usual schedule and can be replayed. Rotations appear in the [audit log](/docs/audit-logs) as `webhook.secret_rotated`. ```ts import { createHmac, timingSafeEqual } from "crypto"; function verifyWebhook(rawBody: string, header: string, secret: string): boolean { const parts = header.split(",").map((p) => p.split("=")); const t = parts.find(([k]) => k === "t")?.[1]; const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v); // two during a rotation if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5 min tolerance const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); return sigs.some((v1) => v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))); } ``` Compute the HMAC over the **raw request body**; parse the JSON only after the signature checks out. Both SDKs ship this check, with the 5-minute replay window built in. They throw (`code: "invalid_signature"`) on a bad signature and return the parsed event otherwise: ```js import { verifyWebhook } from 'jointextkit'; const event = await verifyWebhook({ payload: rawBody, // string or Buffer, unparsed signature: req.headers['textkit-signature'], secret: process.env.TEXTKIT_WEBHOOK_SECRET, }); ``` ```python from jointextkit import verify_webhook event = verify_webhook( request.body, # raw bytes, unparsed request.headers["textkit-signature"], os.environ["TEXTKIT_WEBHOOK_SECRET"], ) ``` Pass `headers: req.headers` (Node) or `request.headers` (Python) in place of the signature and the SDK reads it from whichever header name arrived. Events about a message, verification, or number that belongs to one of your [customers](/docs/customers) carry `data.customer_id`. ## Retries Respond with any 2xx within 5 seconds. Anything else (including a timeout) is retried with backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours. Deliveries can arrive out of order and, rarely, more than once; use the `id` field to deduplicate. If all six attempts fail, the event goes to **Undelivered events** on the console's Webhooks page, where it stays for 30 days. **Replay** sends it again with one click and clears it if your endpoint answers 2xx. We alert our own team too. Your endpoint keeps receiving new events: we never pause or disable an endpoint because it failed. Only you can do that. ## Delivery log Every attempt is logged for 30 days with the HTTP status your endpoint returned, the time it took to respond, the attempt number, and the first 2 KB of the response body. Open **Deliveries** next to an endpoint in the console, or use the API: ```bash curl "https://api.jointextkit.com/v1/webhooks/deliveries?endpoint_id=we_...&limit=25" \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" ``` ```json { "data": [{ "id": "whd_-O9xk2...", "object": "webhook_delivery", "endpoint_id": "we_a1B2c3D4e5F6", "event_id": "evt_a1B2c3D4e5F6g7H8", "event_type": "message.received", "attempt": 2, "ok": false, "status_code": 500, "error": null, "latency_ms": 212, "response_body": "Internal Server Error", "replay": false, "created_at": "2026-10-01T12:00:00.000Z" }], "has_more": false, "next_cursor": null } ``` Filter with `endpoint_id`, `event_id` (every attempt for one event), or both. `GET /v1/webhooks` lists your endpoint ids. `error` is `timeout` or `unreachable` when your endpoint never answered, and `blocked` when its address is not on the public internet (we deliver only to public addresses, on port 443, and do not follow redirects). We store your endpoint's response, not a second copy of the event, which is always at `GET /v1/events`. ## Replay Send one stored event to **one** endpoint, now: ```bash curl -X POST https://api.jointextkit.com/v1/events/evt_.../replay \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "endpoint_id": "we_..." }' ``` The response has your endpoint's status code and latency. A replay is not retried; if it fails, replay it again. It goes to the endpoint you name even if the endpoint is paused or does not subscribe to that event type, because you asked for it. In the console, use **Replay** on any row of the delivery log. ## Test locally Two commands, no tunnel, no public URL, nothing to configure in the console: ```bash npx jointextkit login npx jointextkit listen --forward-to http://localhost:3000/webhooks ``` `login` opens the console for you to approve and stores a test key ([CLI](/docs/cli#login)). `listen` then prints: ```text Ready. Forwarding events to http://localhost:3000/webhooks Webhook signing secret for this session: whsec_local_3f9c... ``` Set that secret as your handler's webhook secret (for example `TEXTKIT_WEBHOOK_SECRET`) and leave your verification code exactly as it will run in production. In another terminal, make something happen: ```bash npx jointextkit trigger message.received # a simulated inbound SMS npx jointextkit trigger message.delivered # a sandbox send that delivers npx jointextkit trigger message.failed # one that fails ``` Each event is POSTed to your local URL with the same JSON body and the same `textkit-signature` / `textkit-event-id` headers (and the `dsms-` pair) a real delivery carries, and the terminal shows what your server answered: ```text 14:02:11 message.received -> 200 (12ms) evt_a1B2c3D4e5F6g7H8 ``` What to know: - **Nothing connects in to your machine.** The CLI asks the API for new events ([`GET /v1/events/wait`](#long-polling)) and makes the request to localhost itself. - **Your real endpoints are untouched.** `listen` only reads the event log. Endpoints you configured still get their own deliveries, and nothing `listen` does appears in their delivery logs or retries. - **Only new events.** It shows what happens after it starts. Earlier events are in `npx jointextkit events` or the console. - **The secret is per session.** Pass `--secret whsec_...` to keep one across restarts. - **Only your key's mode.** With a test key (what `login` stores) `listen` shows sandbox events only, even on an account with live traffic; run it with a live key to watch live events ([test and live data](/docs/sandbox#test-and-live-data)). - Without `--forward-to` it prints each event; `--json` prints one JSON object per line; `--events message.received,message.failed` filters. ### Long polling `listen` is a loop around one endpoint, which you can call yourself: ```bash curl "https://api.jointextkit.com/v1/events/wait" \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" # { "object": "event_wait", "data": [], "cursor": "-Oabc...", "has_more": false } curl "https://api.jointextkit.com/v1/events/wait?cursor=-Oabc...&timeout=20" \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" ``` The first call returns a cursor meaning "from now". Each later call is held until an event arrives or `timeout` seconds pass (0 to 25, default 20) and returns the new events oldest first, with the cursor to send next. An event can be returned more than once, so de-duplicate by `id`, as you would for webhooks. In the SDKs: `sms.events.wait({ cursor, timeout })` (Node), `client.events.wait(cursor=..., timeout=...)` (Python). ## Test a deployed endpoint Press **Send test** next to any endpoint in the console; a signed `test.ping` fires immediately and the console shows your endpoint's response code and latency. In the sandbox, `POST /v1/test/inbound` emits a real `message.received` through the same pipeline, so you can rehearse your inbound handler before going live. ## Manage endpoints with the API An API key can create, change, pause and delete endpoints too, so a deploy script or an agent can set one up without the console: ```bash curl -X POST https://api.jointextkit.com/v1/webhooks \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/textkit", "events": ["message.received", "message.delivered"] }' ``` ```json { "id": "we_a1B2c3D4e5F6", "object": "webhook_endpoint", "url": "https://example.com/webhooks/textkit", "events": ["message.received", "message.delivered"], "active": true, "mode": "test", "description": null, "created_at": "2026-10-05T12:00:00.000Z", "secret": "whsec_a1B2c3D4e5F6g7H8a1B2c3D4e5F6g7H8" } ``` **`secret` is returned once, here.** Store it now; only a rotation returns a secret again. (The console can still reveal or rotate it.) | Call | Does | | --- | --- | | `POST /v1/webhooks` | Create. `url` required; `events` optional (omit, `"*"` or `[]` for everything); `description` optional, 120 characters. Its `mode` is the key's: it receives only that mode's events ([test and live endpoints](#test-and-live-endpoints)) | | `GET /v1/webhooks` | List (no secrets) | | `GET /v1/webhooks/{id}` | One endpoint (no secret) | | `PATCH /v1/webhooks/{id}` | Change `url`, `events`, `description`, or pause and resume with `active` | | `DELETE /v1/webhooks/{id}` | Remove it | | `POST /v1/webhooks/{id}/rotate_secret` | New signing secret, returned once; `expire_previous_in` keeps the old one signing ([rotation](#verify-signatures)) | | `POST /v1/webhooks/{id}/test` | Send a signed `test.ping` now; returns the endpoint's status code, latency and first 2 KB of its answer | | `POST /v1/webhooks/{id}/replay` | Queue every event that failed to reach it between `since` and `until` (default the last 24 hours; up to 7 days, 50 a call; `more: true` means call again) | The rules are the console's: `https` only, a publicly reachable host (no `localhost` or private addresses; use [`listen`](#test-locally) for those), an unknown event type is a `400` rather than a silent no-op, and an account has at most 5 endpoints. Every change is in the [audit log](/docs/audit-logs) with the key that made it. A [restricted key](/docs/authentication) needs the `webhooks:write` scope; `webhooks` alone is read-only. ```js const endpoint = await sms.webhooks.create({ url: 'https://example.com/webhooks/textkit' }); process.env.TEXTKIT_WEBHOOK_SECRET = endpoint.secret; // shown once await sms.webhooks.test(endpoint.id); // signed test.ping await sms.webhooks.rotateSecret(endpoint.id, { expirePreviousIn: 86400 }); await sms.webhooks.replayFailed(endpoint.id); // last 24 hours await sms.webhooks.update(endpoint.id, { active: false }); await sms.webhooks.delete(endpoint.id); ``` ```python endpoint = client.webhooks.create("https://example.com/webhooks/textkit", events=["message.received"]) client.webhooks.test(endpoint["id"]) client.webhooks.rotate_secret(endpoint["id"], expire_previous_in=86400) client.webhooks.replay_failed(endpoint["id"]) client.webhooks.update(endpoint["id"], active=False) client.webhooks.delete(endpoint["id"]) ``` ## Delivery log and replay Every attempt (first try, retries, tests, replays) is logged per endpoint. Open **Deliveries** next to an endpoint in the console to filter by result and event type, replay a single event, or **Replay all failed** for the last 24 hours to 7 days. Replay-all re-sends each event whose deliveries *all* failed in that window (events a retry already recovered are skipped), once per event, up to 50 per run; they go out within 5 minutes and appear in the log marked `replay`. Because replays reuse the original event `id`, your existing deduplication handles them. **Events** edits an endpoint's subscription in place (same URL, same secret); **Clone** copies the subscription to a new endpoint with its own secret. The whole log exports as CSV from the console or `GET /v1/exports/webhook_deliveries?endpoint_id=we_...&ok=false`. --- # CLI The CLI ships inside the Node SDK package, so there is nothing to install: ```bash npx jointextkit login npx jointextkit listen --forward-to http://localhost:3000/webhooks ``` Those two commands take you from "signed in" to "webhooks verified on my machine". Every command works non-interactively and takes `--json`, so an agent can run them. Installed globally (`npm i -g jointextkit`) the command is `textkit`. ## Login ```bash npx jointextkit login ``` ```text Open this URL in your browser to approve this login: https://jointextkit.com/console/cli?code=BCDF-GHJK Check that the page shows this code: BCDF-GHJK Waiting for approval (expires in 10 minutes)... Logged in to Acme. Test key ssms_sk_test_AbCd... saved to ~/.textkit.json. Sandbox number: +15005550123 (sends use it by default) ``` The page shows the code and the name of the machine that asked. Check the code against your terminal and press **Approve**. The terminal then receives a **new test key**, named "CLI on your-machine", which appears under [API keys](/console/keys) like any other and can be revoked there. - **Test keys only, with the CLI's scopes.** A key issued this way is always a sandbox key, restricted to what the CLI does: send and read messages, Verify, numbers, lookups and events. For a live key or more access, create it in the console and store it with `npx jointextkit login --key ssms_sk_live_...`. - **Approve only a login you started.** The link gives whoever started it a key on your account. If someone sends you one, press **Deny**. - A login request lasts 10 minutes, works once, and needs an account admin. Approvals and denials are in the [audit log](/docs/audit-logs). - The key is written to `~/.textkit.json` (readable only by you) and is never printed. - With `--json`, the first line is `{"status":"pending","verification_url":"...","user_code":"..."}` and the last is `{"status":"approved","sandbox_number":"...",...}`: an agent shows the URL to its user and waits. - `--no-browser` (or `TEXTKIT_NO_BROWSER=1`, or `CI`) prints the URL without trying to open it. ### Without a browser ```bash npx jointextkit login --key ssms_sk_test_... # store a key you already have echo "$KEY" | npx jointextkit login # or pipe it export TEXTKIT_API_KEY=ssms_sk_test_... # or skip the file entirely ``` Which key a command uses, first match wins: `--key`, then `TEXTKIT_API_KEY` (then the older `DELIVERED_API_KEY`), then the stored file. ## Listen ```bash npx jointextkit listen --forward-to http://localhost:3000/webhooks ``` Replays each new event on your account to a local URL, signed like a real webhook with a secret it prints at startup. No tunnel. Full walkthrough: [Webhooks: Test locally](/docs/webhooks#test-locally). | Flag | Does | | --- | --- | | `--forward-to ` | POST each event here. Without it, events are printed | | `--events a,b` | Only these event types | | `--secret whsec_...` | Sign with this secret instead of a new one each session | | `--json` | One JSON object per line: a `ready` line, then `{"type":"event","event":{...},"forward":{"status":200,"ms":12}}` | It shows only events that happen after it starts, reconnects by itself if the connection drops, and stops on Ctrl+C. It reads the event log and nothing else: your configured endpoints and their delivery logs are not affected. ## Trigger ```bash npx jointextkit trigger message.received ``` Makes a sandbox event happen, so `listen` has something to show. Test keys only; nothing reaches a carrier. | Event | What it does | | --- | --- | | `message.received` | Simulates an inbound SMS to your sandbox number (`POST /v1/test/inbound`) | | `message.delivered`, `message.sent` | A sandbox send to `+15005550006`: `message.sent`, then `message.delivered` | | `message.failed` | A sandbox send to `+15005550002`: `message.sent`, then `message.failed` | Words after the event name become the message body; `--from` and `--to` override the numbers. Other events come from doing the thing itself with a test key, for example `npx jointextkit verify +15005550006` for `verification.sent`. ## Everything else ```bash npx jointextkit send --to +15005550006 "Hello" npx jointextkit verify +14155550132 # send a code npx jointextkit verify +14155550132 482193 # check it npx jointextkit numbers list | search 415 | buy +1... | release +1... npx jointextkit lookup +14155550132 npx jointextkit messages --limit 10 npx jointextkit events --limit 10 ``` Add `--json` to any of them for the raw API object. The commands `deliveredsms` and `dsms` from before the rename still run. --- # MCP server Connect TextKit to Claude, Cursor or any MCP client and the agent can send texts, buy numbers, load contacts, build segments, run broadcasts and set up automations on your account. The server is `https://mcp.jointextkit.com`. Sign in to connect, or pass an API key. ## Connect from Claude **claude.ai and Claude Desktop:** Settings → Connectors → Add custom connector, URL `https://mcp.jointextkit.com`. Claude sends you to TextKit to sign in. Approve the connection, pick test or live data, and you are back in Claude. **Claude Code:** ```bash claude mcp add --transport http textkit https://mcp.jointextkit.com ``` Then run `/mcp` in Claude Code and choose TextKit to sign in. Approving creates an API key named for the app, listed on the [API keys](/console/keys) page; revoke it there to disconnect. Connecting the same app again replaces its key. Only an account admin can approve. ## Connect from ChatGPT In ChatGPT, open Plugins, select +, then Add custom MCP server, with the URL `https://mcp.jointextkit.com/chatgpt` and OAuth, and create it as a plugin. ChatGPT sends you to TextKit to sign in; approve it the same way. This URL has every tool except Verify and API keys: ChatGPT does not allow one-time codes. Step-by-step guides for Lovable, Replit, Bolt, v0, VS Code, Gemini CLI, Cline and more are on the [integrations](/integrations) page. ## Add to Replit or Cursor [Add to Replit](https://replit.com/integrations?mcp=eyJkaXNwbGF5TmFtZSI6IlRleHRLaXQiLCJiYXNlVXJsIjoiaHR0cHM6Ly9tY3Auam9pbnRleHRraXQuY29tIiwiaGVhZGVycyI6W119) · [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=textkit&config=eyJ1cmwiOiJodHRwczovL21jcC5qb2ludGV4dGtpdC5jb20ifQ==) Both open the app with TextKit filled in; sign in when it asks. In Replit, Agent can then text from the apps it builds: ask it to create an API key with `create_api_key` and store it as the secret `TEXTKIT_API_KEY`. ## Connect with an API key Any MCP client that can send a header: ```json { "mcpServers": { "textkit": { "url": "https://mcp.jointextkit.com", "headers": { "Authorization": "Bearer ssms_sk_test_..." } } } } ``` Start with test data: everything runs in the [sandbox](/docs/sandbox) and no real phone gets a text. Every tool is one API call with your key, so its scopes, rate limits and send checks apply unchanged. To limit what an agent can do, give it a key with only the [scopes](/docs/authentication) it needs. ## Things to ask > Buy a number in area code 512 and text +15005550006 "Your order shipped." > Import the contacts in this CSV, tag them spring-sale, and tell me how many > have an opt-in. > Send everyone tagged vip "Doors open at 9 on Saturday." Do a dry run first > and show me the count and cost. > When someone triggers cart_abandoned, wait an hour, and text them a reminder > unless checkout_completed arrives first. > How did yesterday's broadcast do? Show delivered, failed, replies and > opt-outs. ## Bring an audience from any tool Your people can be anywhere: an analytics tool, a CRM, a store, an email tool, billing, your own database, a spreadsheet or a file. Ask for them and the agent works out how to get them. > Import my users who can get marketing texts. If you have not said where they are, the agent asks (`plan_import`): where your people live, which group, what you can sign in to, whether consent was recorded, and once or kept in sync. Then it offers the ways in, best first: | Way in | When | |---|---| | Tools already in the chat | The agent has that tool's MCP server, a connector, or database access: nothing to set up | | Integration | Shopify, HubSpot, Salesforce, Mixpanel, Amplitude, synced every 6 hours. You type the credentials into the console link it gives, never into the chat | | Push | Segment, RudderStack, Hightouch, Census or a cohort webhook posts people as they change | | Script | A key you keep in your own environment; the agent writes the code | | Export or CSV | The agent tells you where the export button is, then reads the file or you upload it in the console | > Bring the "Trial ending this week" cohort from Mixpanel into TextKit and > text them a reminder about the upgrade offer. With a source named, the agent then: 1. Reads the cohort with the other tool: phone, name, its id there, any SMS consent it recorded, and a few attributes worth merging. 2. Calls `import_contacts` with `dry_run: true` and shows you what will be created, updated, skipped or already opted out. Up to 500 per call. 3. Imports them tagged (for example `mixpanel-trial-ending`) and saves a segment on that tag. 4. Calls `send_batch` with `dry_run: true`: recipients, cost, and who is removed and why. It sends after you approve. Run it again later to refresh: contacts are matched by phone number, and the agent removes the tag from people who left the cohort. **Consent comes from the source, never from the agent.** A cohort is not permission to text. Marketing goes only to people with an opt-in on file, so the agent carries over `opt_in` only where the source recorded one (when, and where it was collected). Without it they can still get transactional texts. See [opt-out and consent](/docs/opt-out). ## Prompts Three built-in playbooks show up as commands in clients that support MCP prompts (in Claude Code: `/mcp__textkit__import_audience`): | Prompt | Arguments | What it does | |---|---|---| | `import_audience` | `source`, `audience`, `tag` (all optional) | Asks where your people are if you have not said, offers every way in, imports with a dry run first, keeps the tag in sync, saves a segment | | `send_campaign` | `audience`, `goal` | Finds or imports the audience, drafts the text, dry-runs it, sends or schedules after you approve | | `campaign_report` | `period` | Delivery, replies, opt-outs and spend per batch, the trend, and a follow-up cohort | ## Tools | Area | Tools | |---|---| | Messages | `send_message`, `get_message`, `list_messages`, `delete_message`, `redact_message`, `simulate_inbound`, `list_events` | | Verification | `send_verification`, `check_verification`, `fallback_verification`, `get_verification` | | Numbers | `list_numbers`, `get_number`, `search_available_numbers`, `purchase_number`, `bulk_purchase_numbers`, `bulk_update_numbers`, `release_number`, `link_number_to_registration`, `assign_number_to_customer`, `export_numbers`, `lookup_phone` | | Sender pools | `list_number_pools`, `create_number_pool`, `get_number_pool`, `update_number_pool`, `delete_number_pool`, `clone_number_pool` | | Contacts | `plan_import`, `import_contacts`, `upsert_contact`, `list_contacts`, `get_contact`, `update_contact`, `delete_contact`, `bulk_update_contacts`, `set_contact_topic` | | Segments and topics | `query_audience`, `create_segment`, `list_segments`, `get_segment`, `update_segment`, `delete_segment`, `preview_segment`, `list_topics`, `create_topic`, `get_topic`, `update_topic`, `delete_topic`, `get_contact_topics` | | Consent | `get_consent`, `set_consent`, `list_opt_outs`, `import_opt_outs` | | Templates | `create_template`, `list_templates`, `get_template`, `update_template`, `delete_template` | | Broadcasts | `send_batch`, `list_batches`, `get_batch`, `get_batch_results`, `list_batch_recipients`, `pause_batch`, `resume_batch`, `cancel_batch`, `retry_batch` | | Scheduled and recurring | `list_scheduled_messages`, `get_scheduled_message`, `cancel_scheduled_message`, `create_schedule`, `list_schedules`, `get_schedule`, `update_schedule`, `delete_schedule` | | Automations | `track_event`, `create_automation`, `list_automations`, `get_automation`, `update_automation`, `delete_automation`, `activate_automation`, `pause_automation`, `list_automation_runs`, `get_automation_run`, `cancel_automation_run` | | Account | `get_balance`, `get_usage`, `get_spend_limit`, `set_spend_limit`, `get_deliverability`, `list_audit_logs`, `list_registrations`, `get_registration`, `list_registration_submissions`, `list_toll_free_verifications`, `get_toll_free_verification`, `forget_identity` | | Reports and exports | `export_csv` (messages, deliveries, events, opt-outs, usage, audit log, credit history), `export_consent`, `wait_for_events` | | Webhooks | `create_webhook`, `list_webhooks`, `get_webhook`, `update_webhook`, `delete_webhook`, `rotate_webhook_secret`, `test_webhook`, `replay_failed_webhooks`, `list_webhook_deliveries`, `replay_event` | | Customers (platforms) | `create_customer`, `list_customers`, `get_customer`, `update_customer`, `delete_customer`, `get_customer_usage` | Tools that send, delete or turn something on are marked `destructiveHint`, read-only ones `readOnlyHint`, so your client can ask before running them. Each tool is one call to the [API](/docs/api) and takes the same inputs, so a new field or list filter there shows up in the tool too. A failed call returns the API's error object with `isError: true`. Filing a 10DLC registration or toll-free verification (they carry a business attestation and fees) and fetching the webhook signing secret stay in the console and the API. ## Cohorts Ask about your audience in plain words and the agent turns it into segment rules: "who replied in the last 7 days", "who got Friday's broadcast and didn't answer", "who started checkout but never bought". `query_audience` answers with the counts and the first contacts without saving anything; `create_segment` saves the cohort so `send_batch` can text it. See [segments](/docs/contacts#segments) for every rule. ## Protocol Streamable HTTP, stateless JSON (no sessions or SSE), protocol version `2025-03-26`. `initialize`, `tools/list` and `prompts/list` work without a key; `tools/call` needs one. Discovery: [/.well-known/mcp.json](/.well-known/mcp.json). --- # Migrate from Twilio Verify Two calls become two calls. The main differences: no Verify Service to create, no phone number to buy, and you're billed only when a code actually verifies. ## Send a code ```js // Twilio await twilio.verify.v2.services(SERVICE_SID) .verifications.create({ to: phone, channel: 'sms' }); // TextKit await fetch('https://api.jointextkit.com/v1/verify', { method: 'POST', headers: { Authorization: `Bearer ${process.env.TEXTKIT_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to: phone }), }); ``` ## Check a code ```js // Twilio const check = await twilio.verify.v2.services(SERVICE_SID) .verificationChecks.create({ to: phone, code }); if (check.status === 'approved') { /* ... */ } // TextKit const res = await fetch('https://api.jointextkit.com/v1/verify/check', { method: 'POST', headers: { Authorization: `Bearer ${process.env.TEXTKIT_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ to: phone, code }), }); const { verified } = await res.json(); if (verified) { /* ... */ } ``` ## In Python ```python # Twilio client.verify.v2.services(SERVICE_SID).verifications.create(to=phone, channel="sms") check = client.verify.v2.services(SERVICE_SID).verification_checks.create(to=phone, code=code) ok = check.status == "approved" # TextKit: pip install jointextkit from jointextkit import TextKit sms = TextKit() # reads TEXTKIT_API_KEY sms.verify.send(to=phone) ok = sms.verify.check(to=phone, code=code)["verified"] ``` ## What maps to what | Twilio | TextKit | | --- | --- | | `to` | `to` (or `phone`, both work) | | Account SID + Auth Token | one API key | | Verify Service SID | nothing; no service to create | | A purchased phone number | nothing; we send from our own numbers | | `check.status === 'approved'` | `verified === true` | | `status: 'pending'` | `verified: false`, `status: 'pending'` | | 404 on bad code | `200` with `verified: false` (a wrong code isn't an exception) | | Fraud Guard | Shield, always on | ## Bring your opt-outs If you also send messages (not only verification codes) from Twilio, export the numbers that opted out there and import them before you send from TextKit. Twilio does not provide its opt-out list through an API, so it is a file you export and upload: one phone number per row, with or without a header. Console → Numbers → Bring your numbers → **Bring your opt-outs**, or `POST /v1/consent/import`. The console import of numbers, pools and webhooks does not run until this is done, or until you confirm you have no opt-outs to carry over. Details: [Bring your opt-outs](/docs/opt-out#bring-your-opt-outs). ## Things that get simpler - **No Verify Service.** Delete the `VA...` SID from your config. - **No number.** Delete the number provisioning step entirely. - **Billing follows success.** We charge when `verified` comes back true, so pumping attacks and abandoned signups cost you nothing. - **Attempt/expiry state is in the response.** `attempts_remaining` and `expires_in` let you render "2 tries left" and a countdown without tracking anything yourself. ## Things to watch - **US and Canada only** right now. If you verify internationally, keep Twilio for those routes or talk to us. - **The SDK is optional.** The plain `fetch` calls above are the whole integration; `npm install jointextkit` adds typed errors, retries, and the CLI. - Sandbox has no resend cooldown so you can iterate; live enforces 60 seconds per number. --- # Numbers ## Search available numbers `GET /v1/numbers/available?area_code=415` Returns up to 5 available numbers. In the sandbox this is a deterministic fake inventory; with live access it searches real US/Canada inventory across 200+ area codes. ## Purchase a number `POST /v1/numbers` with `{ "phone_number": "+1..." }` (optionally `"customer_id": "cus_..."` to assign it to one of your [customers](/docs/customers)) Adds the number to your account (quota applies: default 2 live numbers, 3 sandbox). Returns the Number object. Emits a `number.purchased` event. Send an `Idempotency-Key` header and a retried purchase returns the original `201`. Without one, the retry gets "You already own this number." ## List your numbers `GET /v1/numbers` (`?customer_id=cus_...` for one customer's). Each number carries `customer_id` (`null` when unassigned) and `sender` (below). `GET /v1/numbers/+14155550132` returns one. ## Sender status A live US number works the moment you buy it, with one restriction: until it is registered with the carriers it is **test only**. It can text **your verified numbers** (your own phone, and any number you verify under Billing → Verified numbers), and nobody else. That is enough to build and test your integration end to end while the registration is in review. Once the number is linked to an approved [registration](/docs/compliance) it can text anyone. Every number carries its standing: ```json "sender": { "state": "pending", "registration_id": "reg_a1B2c3D4e5F6", "reason": null, "updated_at": "2026-10-04T16:20:00.000Z" } ``` | State | Meaning | Can text | | --- | --- | --- | | `test_only` | No registration has been submitted for it | Your verified numbers | | `pending` | A registration is in carrier review, or approved and being linked | Your verified numbers | | `active` | Linked to an approved registration | Anyone | | `action_needed` | Something needs you: the registration was rejected, its website check fails, or the link failed. `reason` says exactly what to do | Your verified numbers | `sender` is `null` on sandbox numbers (they never reach a real phone) and on numbers that need no registration (toll-free and Canadian; see below). ### Toll-free numbers A toll-free number (800, 833, 844, 855, 866, 877, 888) is not registered: it is **verified** with the carriers for one business and one use case. Until its verification is approved it texts your verified numbers only, like a `test_only` local number; once approved it texts anyone who opted in, and its texts and STOP/HELP replies name the verified business. You can buy one before your account has live access: add credit, buy it, then verify it in the console (Numbers → Toll-free verification): you give your legal business name, EIN, registered address and what you send, and we fill in the rest. When we know your website, we read its public pages (with the fixed rules of [Fill in from your website](/docs/compliance#fill-in-from-your-website)) and fill in your legal name, brand and address where they are still blank, marked for you to check, and use your own privacy policy and terms (we never write them for you; the [website check](/docs/compliance#what-the-website-check-looks-for) says what they need); **Fill from website** reads it again. We also fill in you as the contact, from your account; a description of your program; the volume; the HELP and STOP replies, example messages, your website, and, when you have no registration page of your own, a [hosted opt-in page](/docs/compliance#hosted-opt-in-page) that links your own terms and privacy policy. Everything we filled in stays editable. When you submit, you sign for the business the same way as for a [registration](/docs/compliance) (once per business: your name, your title and a short statement that you can act for it). Our team checks the form, usually the same business day, then files it with the carriers, with a screenshot of your opt-in page, and the carriers write to our support address, not yours: we answer what we can and forward anything only you can answer. You are told when it is approved (usually days, sometimes weeks). When the carriers approve it, that also verifies the business with us (no separate [business review](/docs/compliance#business-verification) to wait for), and an account that had no live access goes live. **Sending something else.** An approved verification can't be changed. To send what a number isn't verified for (marketing from a number verified for codes, say), add another toll-free number: its form starts as a copy of the verified one, you pick the new use case, and the first number keeps its approval. **Before your account is live.** Once you own a live toll-free number you can create a **live API key** without waiting for live access. Until the account goes live, live sends (`POST /v1/messages`, and the console's Send message) go out **only from that toll-free number and only to your verified numbers**, billed from your credit like any live message; a send from another number, or to anyone else, answers `403 sender_not_registered` and is not charged. Such a key can also read messages, numbers, your balance and usage; every other live endpoint answers `403 live_access_required` until the account is live, and then the same key works everywhere. Each live toll-free number carries its verification: ```json "toll_free": { "status": "verified", "business": "Example Dental", "use_case": "Appointment reminders", "verified_at": "2026-10-20T16:00:00.000Z" } ``` `status` is `not_started`, `pending`, `verified` or `rejected`. `toll_free` is `null` on every other number. Canadian numbers cannot be registered through us yet and text your verified numbers only. `status` is `pending` from when you submit the form until the carriers answer. Toll-free numbers need no 10DLC registration or registration fees, and their `sender` is `null`, but the carriers verify **each** toll-free number: a number you buy later gets a form of its own, filled in as a copy of your last one. A local number is the other way round: once its registration is approved, up to 49 numbers join it with no new review. ### Toll-free verification by API Platforms can verify their users' toll-free numbers without the console. It is the same form, filled in the same way, and it goes to the same review. 1. `POST /v1/toll-free-verifications` with the numbers and anything you know (legal name, EIN, address, use case). We fill in the rest and return a `draft` with `problems`: what the carriers would send it back for. 2. `PATCH /v1/toll-free-verifications/{id}` until `ready` is `true`. 3. `POST /v1/toll-free-verifications/{id}/submit`, with the business attestation the first time for a business. It comes back `in_review`. ```bash curl -X POST https://api.jointextkit.com/v1/toll-free-verifications \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"numbers":["+18335550100"],"business_name":"Acme Plumbing LLC","ein":"12-3456789","street":"1 Main St","city":"Springfield","state":"IL","postal_code":"62701","use_case":"customer_care"}' ``` | `status` | What it means | Who acts | | --- | --- | --- | | `draft` | Not submitted | You | | `in_review` | A person on our team is checking it, usually within a business day. Nothing has gone to the carriers yet; you can still change it | Us | | `submitted` | We sent it to the carriers. Usually days, sometimes weeks | The carriers | | `verified` | Its numbers text anyone who opted in | Nobody | | `rejected` | Sent back. `rejection.reason` says why: fix it with `PATCH`, then submit again | You | Every form is checked by a person before it goes to the carriers; the API never sends one straight there. Poll `GET /v1/toll-free-verifications/{id}` for the answer; we also email you when it is verified or sent back. The calls need a key with the `registrations` scope. A verification can't change once it is with the carriers or verified. You do not have to do anything to move a number along. Submit one registration and every live number on the account follows it: `pending` while the carriers review, then linked and `active` when the registration is approved. A number bought later links on its own, usually within 1 business day. Each change emits `number.sender_updated` (`data`: `phone_number`, `state`, `previous_state`, `registration_id`, `reason`). If our team ever records an approval by mistake (nothing was filed with the carriers), we undo it: the registration goes back to our filing queue, or back to you with a reason, and its numbers leave `active` (`pending` or `action_needed`, with the same event and an entry in your [audit log](/docs/audit-logs)). A send to anyone else from a number that is not `active` answers **403** `sender_not_registered` with an `X-TextKit-Sender-State` header and a message that says what to do. Nothing is sent and nothing is charged. Broadcasts, scheduled sends and automations follow the same rule: those recipients fail with `sender_not_registered`, never silently. ### Choose a registration for a number `POST /v1/numbers/+14155550132/registration` with `{ "registration_id": "reg_..." }` Only needed when your account has more than one approved registration (the number is `action_needed` until you choose), or to retry a link that failed. You can also pass `registration_id` when you buy the number. One registration holds up to **49 numbers**, the most carriers allow; attaching a 50th answers `409`. Returns the number with its new `sender`. ## Assign a number to a customer `PATCH /v1/numbers/+15005550132` with `{ "customer_id": "cus_..." }`, or `{ "customer_id": null }` to unassign. Messages to and from the number are attributed to that customer from then on; earlier messages keep theirs. ## Release a number `DELETE /v1/numbers/+15005550132` Marks the number released (live mode removes it from its registration, then disconnects it at the carrier). Rate limited to 10 releases per 30 minutes. Emits `number.released`. > Live number purchase and release require live access; sandbox > numbers work for everyone immediately. ## Request a short code Short codes aren't bought on the spot: carriers approve each one, in 8 to 12 weeks. File the request and our team sets it up with you by email: ```bash curl -X POST https://api.jointextkit.com/v1/numbers/requests \ -H "Authorization: Bearer ssms_sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "short_code", "business_name": "Acme Inc", "monthly_volume": "over_100k", "use_case": "Order and delivery updates to customers who opted in at checkout." }' ``` `monthly_volume` is `under_1k`, `1k_10k`, `10k_100k` or `over_100k`. One open request per type; asking again updates it. `GET /v1/numbers/requests` lists the open ones. Short-code carrier fees are passed through at cost, on top of the per-segment rate. The console's **Add a number** has the same form. ## Buy in bulk `POST /v1/numbers/bulk-purchase` ```json { "area_code": "415", "quantity": 10, "pool_id": "pool_a1B2c3D4e5F6", "tags": ["spring"] } ``` Up to 50 per call. Your number limit is checked for the whole `quantity` first: if it doesn't fit you get `429 quota_exceeded` saying how many more you can add, and nothing is bought. After that, a number the carrier refuses is listed in `failed` and replaced from spare inventory; `shortfall` is how many the area code couldn't supply. Test keys mint sandbox numbers. ### Replacing a filtered number If you release a live number that carriers were filtering as spam and buy another within 7 days, new live numbers are put on hold for your account: purchases answer `403 number_purchase_held` until our team has reviewed it, and the owner and admins are emailed. Your existing numbers keep working. Carriers treat swapping a filtered number for a fresh one as evading their filters ("number cycling") and fine it; a new number doesn't fix what got the old one filtered, so look at the wording, the opt-in and the volume instead. ## Tags, labels, and bulk actions Every number carries an optional `label` ("Front desk") and up to 20 `tags` (lowercased). Filter on them anywhere: `GET /v1/numbers?tag=spring&pool=pool_...&mode=live&q=front`. `POST /v1/numbers/bulk` applies one action to up to 1000 numbers: | `action` | Extra field | | --- | --- | | `add_tags` / `remove_tags` | `tags: [...]` | | `set_label` | `label` (`null` clears) | | `add_to_pool` / `remove_from_pool` | `pool_id` | | `release` | `confirm: "RELEASE "` | The response is always one result per id you sent, so a batch where 3 of 143 ids were wrong tells you which 3: ```json { "object": "bulk_result", "action": "add_tags", "requested": 143, "succeeded": 140, "failed": 3, "results": [{ "id": "+14155550132", "ok": true }, { "id": "+1415555", "ok": false, "error": "invalid_id" }] } ``` Per-id errors: `invalid_id`, `duplicate`, `not_found`, `too_many_tags`, `mode_mismatch` (a test key releasing a live number), `carrier_error`. **Release is guarded.** Up to 100 per call, and `confirm` must be `"RELEASE "`, so a script that built the wrong list fails before anything is disconnected. Released live numbers go back to carrier inventory and may not be recoverable. ## Sender pools A pool is a named group of your numbers. Send with `"from": "pool_..."` and TextKit picks a member, **the same one for each recipient every time**, so replies stay in one thread on their phone. Live keys draw only live members; test keys only sandbox members. ```bash curl https://api.jointextkit.com/v1/numbers/pools \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Support"}' ``` `GET`/`PATCH`/`DELETE /v1/numbers/pools/:id`, and `POST /v1/numbers/pools/:id/clone` copies a pool (same description and members, new id). Numbers can sit in several pools. Deleting a pool keeps its numbers. Membership is set with the bulk actions above. ### Local numbers Create or patch a pool with `"routing": "local"` and a recipient gets a member in their area code when the pool has one, else one in their time zone, else any member. It is still the same number for that recipient every time. The default, `"sticky"`, ignores location. ## Throughput `GET /v1/numbers/:id` returns `throughput`: the messages a minute TextKit holds the number to. ```json "throughput": { "per_minute": 240, "shared_by": "registration", "registration_id": "reg_..." } ``` The pace belongs to the number's registration and is shared by every number linked to it: 240 a minute, 75 for a low-volume registration. A direct send over it gets `429 rate_limited` with Retry-After; batches and scheduled messages wait for the next minute instead. `null` means no TextKit pace applies (the number is not linked to a registration); carrier limits still do. ## Export `GET /v1/numbers/export` returns CSV (`phone_number, mode, label, tags, pools, pool_ids, created_at`) and takes the same filters as the list. The console exports a filter or an exact selection. --- # Customers For platforms (SaaS, agent builders, agencies) that send on behalf of other businesses. A customer is a label you control: assign numbers to it, and every message, verification, event and webhook from those numbers carries its `customer_id`, with usage you can read back per customer to rebill. Entirely optional. Accounts that never create a customer see no change. ## Create a customer `POST /v1/customers` ```bash curl -X POST https://api.jointextkit.com/v1/customers \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Dental", "external_id": "acct_42", "metadata": { "plan": "pro" } }' ``` ```json { "id": "cus_a1B2c3D4e5F6g7", "object": "customer", "name": "Acme Dental", "external_id": "acct_42", "metadata": { "plan": "pro" }, "created_at": "2026-10-01T12:00:00.000Z", "updated_at": "2026-10-01T12:00:00.000Z" } ``` | Parameter | Type | Notes | | --- | --- | --- | | `name` | string | Optional, up to 200 characters. | | `external_id` | string | Optional. Your id for them; unique per account (`409` if taken). | | `metadata` | object | Optional. Up to 50 string values. Keys up to 40 characters, no `. # $ / [ ]`. | ## Retrieve, update, delete, list - `GET /v1/customers/{id}` also returns `phone_numbers` assigned to it. - `PATCH /v1/customers/{id}` changes only the fields you send; `metadata` replaces the whole object, `null` clears a field. - `DELETE /v1/customers/{id}` deletes it and unassigns its numbers (they stay on your account). Messages, events and usage already attributed keep the id: they record what happened. - `GET /v1/customers?limit=25&cursor=...` lists newest first; `?external_id=acct_42` finds one by your id. ## Attribute traffic - **Numbers**: `POST /v1/numbers` with `customer_id`, or `PATCH /v1/numbers/{number}` with `{ "customer_id": "cus_..." }`. - **Outbound messages** inherit the customer of their `from` number, or take an explicit `customer_id`. A number assigned to one customer can't send as another (`400`); unassigned numbers can send for anyone. - **Inbound messages** (and STOP/HELP replies) inherit the customer of the number they arrived on. - **Verifications** take an optional `customer_id` on `POST /v1/verify`. - **Events and webhooks** about any of the above carry `data.customer_id`. - `GET /v1/messages?customer_id=cus_...` lists one customer's messages. ## Usage `GET /v1/customers/{id}/usage?start=2026-10-01&end=2026-10-31` ```json { "object": "customer_usage", "customer_id": "cus_a1B2c3D4e5F6g7", "mode": "live", "start": "2026-10-01", "end": "2026-10-31", "messages_sent": 1204, "messages_failed": 3, "messages_received": 377, "verifications_sent": 88, "verifications_approved": 81, "numbers": 2, "phone_numbers": ["+14155550132", "+14155550133"], "daily": [{ "date": "2026-10-01", "messages_sent": 40, "...": 0 }] } ``` UTC days, inclusive, up to 366 days; the default is this month to date. Usage follows the key you call with: a test key reports sandbox traffic and a live key live traffic, so test runs never show up in what you rebill. `numbers` is the count assigned right now. ## With the SDKs ```js const acme = await sms.customers.create({ name: 'Acme Dental', externalId: 'acct_42' }); await sms.numbers.update('+14155550132', { customerId: acme.id }); await sms.messages.list({ customerId: acme.id }); await sms.customers.usage(acme.id, { start: '2026-10-01', end: '2026-10-31' }); ``` ```python acme = client.customers.create(name="Acme Dental", external_id="acct_42") client.numbers.update("+14155550132", customer_id=acme["id"]) client.messages.list(customer_id=acme["id"]) client.customers.usage(acme["id"], start="2026-10-01", end="2026-10-31") ``` ## Responsibility A customer label is bookkeeping for you. It does not register the business with carriers, make it our customer, or change who is responsible for its traffic: you are, as if it were your own ([Terms §2.4](/terms), [Messaging Policy §7](/messaging-policy)). Each end business still needs its own brand and campaign registration. --- # Lookup A lookup tells you which carrier a US or Canada number is on and whether it is a mobile, a landline or a VoIP line. Run one before you text a list to skip landlines, or at signup to reject VoIP numbers. ## Look up a number `GET /v1/lookup/{phone}` ```bash curl https://api.jointextkit.com/v1/lookup/+14155550132 \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" ``` ```json { "phone_number": "+14155550132", "valid": true, "line_type": "mobile", "carrier": { "name": "Sandbox Wireless", "type": "mobile" }, "caller_name": null } ``` The number can be written any way you like: `+14155550132`, `4155550132` and `(415) 555-0132` are the same lookup. It must have 10 digits, or 11 starting with 1. The response always carries the E.164 form. A restricted key needs the `lookup` [scope](/docs/authentication#scopes). ## Response fields | Field | Notes | | --- | --- | | `phone_number` | The number you asked about, normalized to E.164. | | `valid` | `false` only when the carrier network reports that the number does not exist (unassigned or vacant). Everything else is `true`, including a number whose line type is unknown. | | `line_type` | `mobile`, `landline`, `voip`, or `null`. `null` means we could not classify the carrier; it is never a guess. | | `carrier.name` | The carrier the number is on today. It follows ports, so a number moved to another carrier shows the new one. `null` when the network returned no name. | | `carrier.type` | The same value as `line_type`. | | `caller_name` | Always `null` today. The field is reserved, so check for `null` before using it. | A successful response also carries an `X-Quota-Remaining` header: the lookups left in your account's day. ## With the SDKs ```js import { TextKit } from 'jointextkit'; const sms = new TextKit(process.env.TEXTKIT_API_KEY); const result = await sms.lookup.phone('+14155550132'); if (result.line_type === 'landline') { // skip: this number cannot receive texts } ``` ```python from jointextkit import TextKit client = TextKit() # reads TEXTKIT_API_KEY result = client.lookup.phone("+14155550132") print(result["line_type"], result["carrier"]["name"]) ``` ## Test keys A test key never contacts a carrier and is never billed. The answer is decided by the number alone, so your tests get the same result every time: | Number | `line_type` | `carrier.name` | | --- | --- | --- | | Last digit even | `mobile` | `Sandbox Wireless` | | Last digit odd | `landline` | `Sandbox Telecom` | | `+15005550010` | `landline` | `Sandbox Telecom` | `+15005550010` is the [sandbox](/docs/sandbox) landline number, so it answers `landline` although it ends in 0. `valid` is always `true` and `caller_name` is always `null` with a test key. ## Live keys A live key returns real carrier data. Each answer is cached for 24 hours, so a second lookup of the same number inside a day returns the stored answer, and a port can take up to 24 hours to show. A cached answer still counts as a lookup. On an account with a payment method, each successful live lookup costs $0.008. A lookup that ends in an error is not billed. Sending a message never runs a lookup. The `destination_carrier` field on a [message](/docs/messages) is filled from the same 24-hour cache, so it is `null` unless you looked the number up recently. ## Limits Lookups are counted per account, per UTC day. Test and live keys draw from the same counter. | Account | Lookups per day | | --- | --- | | With a payment method | 5,000 | | New account, no payment method | 100 | | No payment method, after live access is approved | 250 | A lookup that fails with `carrier_error` still uses one of the day's lookups; a request rejected with `invalid_request` does not. The usual limit of 60 requests a minute per key applies too. ## Errors | Status | Code | When | | --- | --- | --- | | `400` | `invalid_request` | The number is not a valid US or Canada number. `param` is `phone`. | | `401` | `invalid_api_key` | The key is missing, malformed or revoked. | | `403` | `insufficient_scope` | A restricted key without the `lookup` scope. `required_scope` names it. | | `403` | `forbidden` | A live key on an account with no lookup allowance. Add a payment method in the console. | | `429` | `quota_exceeded` | The day's lookups are used up. The message states the limit. | | `429` | `rate_limited` | Too many requests in a minute. Wait for the `Retry-After` seconds. | | `502` | `carrier_error` | The carrier network did not answer. Try again shortly. | ```json { "error": { "code": "quota_exceeded", "message": "Daily lookup quota reached (5000/day).", "request_id": "req_a1B2c3D4e5F6g7H8i9J0" } } ``` Every code is explained in [Errors](/docs/errors#codes). ## Retired: spam scores `GET /v1/lookup/{phone}/spam` was retired on 2026-09-30 and answers `410 endpoint_retired`. The Node SDK still has `sms.lookup.spam()`, which now always throws that error. --- # Inbox & conversations Every message on your numbers is grouped into conversations, one per (your number, counterparty) pair. The console inbox is a shared view: your whole team sees the same threads and the same unread state. ## How threading works The thread key is `{ourDigits}_{theirDigits}`: the last 10 digits of your number, an underscore, then the last 10 digits of the other party. A text from `+14155550132` to your number `+15005550100` belongs to `5005550100_4155550132`, and so does your reply. The key does not depend on direction. The same person texting two of your numbers is two conversations. Every message joins its conversation the moment it is stored, whatever created it: an API send, a console send, an inbound text, a simulated inbound, a keyword reply, or an [auto-reply](/docs/auto-reply). Each conversation keeps the first 200 characters of its latest message, that message's direction and status, the time of the last activity, a message count and an unread count. The inbox lists conversations by latest activity, newest first, and shows the [contact](/docs/contacts) name when the other party's number matches one. ## Unread counts An inbound message adds 1 to the conversation's unread count. Outbound messages leave it alone. Opening the thread in the console sets it to 0 for everyone: read state belongs to the conversation, not to a team member, so one person reading a thread clears the badge for the whole team. While the inbox is open it reloads the conversation list and the open thread every 15 seconds. A thread shows its 50 most recent messages. ## The conversation field on webhooks `message.received` events carry a `conversation` field with the thread key, so your own systems can thread without re-deriving it: ```json { "id": "evt_a1B2c3D4e5F6g7H8", "object": "event", "type": "message.received", "created_at": "2026-10-05T17:02:11.204Z", "data": { "message_id": "msg_x9Y8z7W6v5U4t3S2", "to": "+15005550100", "from": "+14155550132", "body": "Hey, got your message!", "conversation": "5005550100_4155550132" } } ``` Only `message.received` has the field. For outbound events such as `message.sent`, build the same key from the message's `from` (your number) and `to`. See [Webhooks](/docs/webhooks#event-types) for the other events. ## Read a thread from the API The API has no conversations endpoint. To read one person's history, list messages with the `number` filter, which matches either side: ```bash curl "https://api.jointextkit.com/v1/messages?number=%2B14155550132&limit=50" \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" ``` The response is the usual list, newest first, with inbound and outbound messages together. `number` matches that person across all of your numbers, so if they have texted more than one, split the results by your side of each message. A restricted key needs the `messages:read` scope. Paging and the other filters are described in [Messages](/docs/messages#list-messages). ```js import { TextKit } from 'jointextkit'; const sms = new TextKit(process.env.TEXTKIT_API_KEY); for await (const message of sms.messages.listAll({ number: '+14155550132' })) { console.log(message.direction, message.body); } ``` ```python from jointextkit import TextKit client = TextKit() # reads TEXTKIT_API_KEY for message in client.messages.list_all(number="+14155550132"): print(message["direction"], message["body"]) ``` ## Replying from the console The composer under a thread sends from the number that thread is on. "New message" starts a thread from the first number on your account. Enter sends; Shift+Enter adds a line. A console reply goes through the same send checks as `POST /v1/messages`, with three additions: - Merge tags such as `{{first_name}}` are filled from the recipient's contact. - If the team member has a signature, it is added on a new line. - You can insert a saved template into the composer. The body may be at most 1600 characters after the merge and the signature. ## Attribution Outbound messages carry a `sent_by` field on the Message object. Console sends have the team member's name, and the thread shows it under the message. Messages sent with an API key, including scheduled ones, have `"sent_by": "API"`. Inbound messages and automatic replies have no `sent_by`. ## Media Inbound MMS attachments are stored on the message as a `media` array of URLs and shown in the thread as links. The `message.received` event does not include them: fetch the message with `GET /v1/messages/{id}` to read `media`. Outbound MMS is not available yet. A `POST /v1/messages` request with a `media` field returns `400 mms_not_enabled`. ## Try it in the sandbox `POST /v1/test/inbound` puts a fake inbound text on one of your sandbox numbers. It is stored and threaded like a real one, so it appears in the inbox with an unread count and emits `message.received`. ```bash curl -X POST https://api.jointextkit.com/v1/test/inbound \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+14155550132", "body": "Hey, got your message!" }' ``` It returns `201` with the inbound Message object (`direction` is `inbound`, `status` is `received`, `test` is `true`). Add a `media` array of up to 10 http or https URLs to simulate an MMS. | Status | Code | When | | --- | --- | --- | | `400` | `invalid_request` | `to` is not one of your numbers, `from` is not a valid US or Canada number, or `body` is empty or longer than 1600 characters. `param` names the field. | | `403` | `test_mode_only` | The request used a live key. | | `403` | `insufficient_scope` | A restricted key without the `messages:send` scope. | The SDKs have no call for this endpoint; use plain HTTP. --- # 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" } ``` | 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, 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](/docs/opt-out#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: | Tool | What syncs | Consent | |---|---|---| | Shopify | Customers, or a customer search like `tag:vip`; abandoned checkouts, orders and shipments for [cart reminders and order texts](/docs/abandoned-cart) | 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 ` 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"} ]}' ``` | `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](/docs/automations) 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: ```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](/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.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](/docs/credits) ledger) and `audit_log` (the [audit log](/docs/audit-logs), 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. --- # Teams One account, many users. The owner and admins manage the account; members work the inbox. ## Roles | Role | Can | | --- | --- | | `admin` | Everything: keys, billing, webhooks, numbers, team, plus all member abilities. | | `member` | Inbox, contacts, broadcasts, templates, messaging. | | `viewer` | Read-only: sees everything a member sees (inbox, messages, contacts, deliverability, usage, spend), changes nothing. | Admin-only routes return `403` to members and viewers; any change a viewer attempts returns `403`. Admins change roles from Console → Team, and every invite, role change, and removal is recorded in the [audit log](/docs/audit-logs). ## Invites Console → Settings → Team → enter an email, pick a role, and select Invite. We email the person a link; it is single-use, expires after 7 days, and only the invited address can accept it. They sign in (Google, GitHub, or email) with that address and land on your team. An account that already belongs to another team must use a different sign-in. Pending invitations are listed with the members. From the `…` menu an admin can resend the email, copy the link, or revoke the invitation. ## Managing members From the `…` menu next to a member, an admin can change their role or remove them. Anyone except the owner can leave the team from the same menu next to their own name. ## Signatures Each user can set a signature (Console → Team → your profile); it is appended to messages they compose in the console. ## Closing your account Only the account owner can close the account: Console → Settings → Close account, then type the account name to confirm. You can also ask us to close it by emailing [support@joinsimplesms.com](mailto:support@joinsimplesms.com). Closing takes effect at once: - Nothing more is sent. Every API key is revoked, and any request made with one, test or live, returns `403 account_closed`. - Webhook endpoints are turned off; recurring schedules and automations are paused; scheduled messages and broadcasts that have not gone out are canceled. - The account's phone numbers are released. A released number goes back to the carrier and may later be given to someone else, so port out any number you want to keep before you close. - Its registrations end: we file nothing more for them and ask the carriers to deactivate them. - Fees accrued up to closing appear on a final statement. Unused purchased credits are not refunded ([Terms §4.5](/terms)). For 30 days the account's people can still sign in and read and export everything (the console is read-only), and a mistaken closure can be reversed by emailing us. After that we delete the account's data, always within 90 days of closing: messages, contacts, logs, the audit log, settings and every user's sign-in. Registration details and filed copies are kept for 1 year, opt-out and consent records for at least 4 years after the last message, and billing records for 7 years, as [Data Retention](/data-retention) says. --- # Batches & broadcasts A batch sends one message body to many recipients as individual texts. Recipients never see each other, and variables personalize each body. Recipients come from a CSV upload (console), `recipients[]` (API), or contacts carrying a tag. ## Send a batch ```bash curl -X POST https://api.jointextkit.com/v1/batches \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "body": "Hi {{first_name}}, order {{order_id}} ships today. Reply STOP to opt out.", "recipients": [ {"to": "+14155550132", "variables": {"first_name": "Jane", "order_id": "A-1042"}}, {"to": "(415) 555-0133", "variables": {"first_name": "Sam", "order_id": "A-1043"}} ] }' ``` The response is a `batch` object (`bc_` id) with live `counts`, plus a `validation` summary of any rows that were dropped. To send to contacts instead, pass `"tags": ["vip", "beta"]` (any of the tags) in place of `recipients`. Add `scheduled_at` (up to 30 days out) to send later. ## Validate first: dry runs Add `"dry_run": true` and nothing is sent. You get counts, every rejected row with its reason, a rendered preview, the segment count and the estimated cost: | Reason | Meaning | |---|---| | `invalid` | Not a valid US/Canada number (e.g. a +44 number) | | `duplicate` | Same number as an earlier row - the first one is kept | | `opted_out` | Replied STOP to you | | `topic_unsubscribed` | Unsubscribed from the batch's `topic_id` | | `missing_variable` | A variable the body uses is blank on this row | | `landline` | Only with `check_line_types: true` (first 500 numbers) | | `empty_after_merge` | The rendered body is empty | A row is counted under its first failing reason, so `valid` plus the rejected counts always equals the number of rows you sent. A variable that no row has at all (a typo like `{{frist_name}}`) fails the whole request with `unknown_variables` instead of sending blanks. The dry run also asks the consent check what it will decide at send time, for the first 1,000 accepted recipients: `consent.no_opt_in` is how many would be refused `consent_required` (a marketing message without an opt-in on file), with up to 20 of them in `consent.no_opt_in_sample`. Record their opt-in, or leave them out; a real send skips them and texts the rest. The estimate is segments × (the per-segment rate + the highest carrier fee), an upper bound: each recipient's carrier fee is known only at send (sandbox: $0). Segments are reported too: one emoji or curly quote switches the whole message to UCS-2, 70 characters per segment instead of 160. ## Variables Any recipient variable (CSV column) is available as `{{column_name}}`; headers are snake_cased ("First Name" → `{{first_name}}`). Contact batches also get `{{name}}`, `{{first_name}}`, `{{phone}}` and `{{field:company}}`, rendered at send time so a contact edit made after scheduling still lands. Unresolvable tags render empty, never as the raw tag. ## Pause, resume, cancel, retry | Endpoint | What it does | |---|---| | `POST /v1/batches/{id}/pause` | Holds everything not yet handed to the carrier | | `POST /v1/batches/{id}/resume` | Re-queues held messages | | `POST /v1/batches/{id}/cancel` | Cancels a scheduled batch, or stops one mid-send | | `POST /v1/batches/{id}/retry` | Re-sends failures that can succeed on a retry | A message already in flight when you pause or cancel still sends. Retry only re-queues transient failures (rate limits, quota, carrier errors), at most 3 attempts per recipient, and only once the batch is `complete`; opt-outs, invalid numbers and anyone this batch already messaged are never retried. Two retries at once can't double-send. ## Progress `GET /v1/batches/{id}` returns `counts`: `total = queued + sent + failed + opted_out + canceled`, always. "Sent" means accepted by the carrier. `GET /v1/batches/{id}/recipients?status=failed` lists rows with `error` and `retryable`. In the console, the batch page shows live progress and a **Download failed rows** CSV (your original columns plus the reason), ready to fix and re-upload. ## Audience and topic Besides `recipients`, a batch can go to contacts: `segment_id` (a saved [segment](/docs/contacts#segments), evaluated when the batch is created), `tags`, or `contact_ids`. `topic_id` names a [subscription topic](/docs/contacts#subscription-topics). Recipients unsubscribed from it are removed at validation (counted as `topic_unsubscribed`) and checked again when each message is sent, exactly like opt-outs; at send time they are counted with `opted_out` and their row's `error` is `topic_unsubscribed`. Omit `topic_id` and only opt-outs apply. Console broadcasts default to Marketing. ## Compliance check The console runs a check before a broadcast is sent (it is part of the dry run). It checks: | Check | Result | | --- | --- | | Sender | Pass in sandbox or with an approved registration; a warning otherwise | | Opt-out wording | Warning when the message has no "Reply STOP" instruction | | Content | **Blocks** on a live sender when the content screen refuses the message; a warning in sandbox | | Public link shortener | Warning | | Quiet hours | Says what will happen: "312 recipients are in quiet hours and will be delivered at 8:00am their time". Each recipient outside 8:00am to 9:00pm local time (8:00pm in Florida, Maryland and Oklahoma; read from the area code) is held until their 8:00am, whatever the message is. Pass `"quiet_hours": "block"` to refuse those recipients instead. See [quiet hours](/docs/compliance-checks#quiet-hours) | | Opt-outs on file | Warning on your first broadcast when you have imported contacts and have no opt-outs on file: [import the list](/docs/opt-out#bring-your-opt-outs) from wherever these people were texted before | | Consent on file | Warning when contacts in the audience have no opt-in recorded at import, and always for an uploaded list. A broadcast is marketing: a recipient with no opt-in on file is skipped with `consent_required` ([opt-in on file](/docs/compliance-checks#opt-in-on-file)) | | Topic | Warning when no topic is set | | Merge variables, empty audience | **Blocks** (the API refuses these too) | Warnings never stop a send: consent, timing and wording are your responsibility under the [Messaging Policy](/messaging-policy). **Send test** texts the first rendered message to your own verified phone. ## Results `GET /v1/batches/{id}/results` (and the console's broadcast page) reports what happened after the queue did its part: ```json { "attempted": 100000, "delivered": 97921, "failed": 1228, "pending": 851, "replies": 1402, "stops": 312, "spent_usd": 1142.28, "failures": [ { "code": "invalid_number", "title": "Number not in service", "count": 640, "explanation": "The destination number does not exist or is no longer assigned to a phone.", "action": "Recommended: stop sending to this number..." } ] } ``` - `attempted = delivered + failed + pending`. Skipped (opted out or unsubscribed), canceled and still-queued recipients are reported separately. - `delivered` and `failed` come from carrier delivery receipts. `pending` was accepted by the carrier with no final receipt; `no_receipt` of those never got one. Sandbox outcomes are simulated. - `replies` are messages recipients sent to the sending number within 72 hours of the batch finishing (STOP keywords are not counted as replies). `stops` are recipients who opted out in that time and are still opted out. - `spent_usd` is the sum of each message's recorded `price`. - `failures` groups failed recipients by [failure code](/docs/errors), plus the reasons a send can be refused before a message exists (allowance reached, sending number released, empty after merge...). In the console, click a reason for the explanation and a CSV of exactly those recipients. - `partial: true` means a read bound was hit on a very large or very busy account: delivered and replies are then floors. Events: `batch.created`, `batch.paused`, `batch.resumed`, `batch.canceled`, `batch.retried`, `batch.complete` (and the legacy `broadcast.complete`, still sent for existing subscribers). ## Opt-outs are enforced per recipient Opted-out numbers are dropped at validation, and every recipient is checked again at send time. Anyone who replies STOP while a batch is running is counted in `opted_out` - never texted, never silently dropped from the math. ## Limits and pacing Up to 10,000 recipients per batch. Messages go out at about 100 per minute, interleaved fairly with your other scheduled sends. Each message counts against your normal quota and billing; a batch is exactly N messages. --- # Scheduled messages To send the same message on a repeating schedule (every morning at 8:00, every Monday, the 1st of each month), use a [recurring schedule](/docs/schedules) instead of queueing one message per day. Pass `scheduled_at` (ISO timestamp or epoch ms, up to 30 days out) to `POST /v1/messages` and the message is queued instead of sent: ```bash # Tomorrow at 15:00 UTC (GNU date, then macOS date). Up to 30 days ahead. SEND_AT=$(date -u -d tomorrow +%Y-%m-%dT15:00:00Z 2>/dev/null || date -u -v+1d +%Y-%m-%dT15:00:00Z) 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": "Reminder: your appointment is tomorrow at 3pm.", "scheduled_at": "'"$SEND_AT"'" }' ``` The response is `201` with a `scheduled_message`, not a Message: ```json { "id": "job_1791817200000a1B2c3D4e5", "object": "scheduled_message", "to": "+15005550006", "from": "+15005550100", "body": "Reminder: your appointment is tomorrow at 3pm.", "scheduled_at": "2026-10-12T15:00:00.000Z", "run_at": "2026-10-12T15:00:00.000Z", "status": "queued" } ``` | Field | Notes | | --- | --- | | `id` | The `job_...` id. Use it to cancel. It is not a message id: the message gets its own `msg_...` id when it is sent. | | `object` | Always `scheduled_message`. | | `to`, `from` | Both numbers in E.164. | | `body` | The text that will be sent. | | `scheduled_at` | When it will be sent, as an ISO timestamp in UTC. The same name as the request field. | | `run_at` | Deprecated alias of `scheduled_at`: the same value under its older name, kept so existing code keeps working. Read `scheduled_at`. | | `status` | `queued` while it waits. A cancel response says `canceled`. | | `customer_id` | Present only when the send is attributed to a [customer](/docs/customers). | | `batch_id` | In list responses only. `null` for a message scheduled this way. | ## When it sends The queue is worked through once a minute. A message goes out on the first pass after `scheduled_at`, so expect it up to a minute after the time you asked for, or later when many sends are due at once. `scheduled_at` is an ISO timestamp string or a number of milliseconds since the epoch. Put a `Z` or a UTC offset on the string so the time is unambiguous. It must be in the future and at most 30 days ahead. ## With the SDKs ```js import { TextKit } from 'jointextkit'; const sms = new TextKit(process.env.TEXTKIT_API_KEY); const job = await sms.messages.schedule({ to: '+15005550006', body: 'Reminder: your appointment is tomorrow at 3pm.', sendAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // tomorrow }); console.log(job.id, job.scheduled_at); ``` ```python from datetime import datetime, timedelta, timezone from jointextkit import TextKit client = TextKit() # reads TEXTKIT_API_KEY job = client.messages.schedule( to="+15005550006", body="Reminder: your appointment is tomorrow at 3pm.", send_at=datetime.now(timezone.utc) + timedelta(days=1), # tomorrow ) print(job["id"], job["scheduled_at"]) ``` `sendAt` and `send_at` take a date object or an ISO string. In Python a datetime without a timezone is read as UTC, and `from_` is optional while your account has one number. The SDKs cover scheduling only; listing, retrieving and canceling are plain HTTP calls. Unlike `messages.send`, `schedule` does not add an idempotency key for you. With plain HTTP, send an `Idempotency-Key` header to make a retry safe. The schedule time is part of the payload, so a retried request never queues a second message ([Idempotency](/docs/messages#idempotency)). ## The schedule reserves nothing Scheduling checks the shape of the request and nothing else. Everything that decides whether a message may go out is checked again at send time: - A recipient who opts out between scheduling and sending is skipped. No message is created and no event fires. - Your quota is taken when the message sends, not when you schedule it. A schedule does not hold quota. - Content screening and the other send checks run at send time. - If the `from` number has left your account by then, nothing is sent. A scheduled send that fails one of these checks is dropped. It is not tried again, and there is no response to carry the error, so schedule only what an immediate send would accept. A temporary carrier problem is different: the message is created as `queued` and gets the normal [automatic retries](/docs/messages#automatic-retries). ## Events and the message Scheduling fires no event. When the message sends you get the same events as an immediate send: `message.sent`, then `message.delivered` or `message.failed` ([Webhooks](/docs/webhooks#event-types)). The message's `timeline` starts with `accepted` at the moment you scheduled it, and its `sent_by` is `API`. To find the message, use the `message_id` on the event or list messages by `to`. ## Sandbox A scheduled message is sent in the mode of its `from` number. From a sandbox number it is simulated and never billed, and the [magic numbers](/docs/sandbox#magic-destination-numbers) behave as they do for an immediate send. ## Listing `GET /v1/scheduled_messages` ```bash curl https://api.jointextkit.com/v1/scheduled_messages \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" ``` ```json { "data": [ { "id": "job_1791817200000a1B2c3D4e5", "object": "scheduled_message", "to": "+15005550006", "from": "+15005550100", "body": "Reminder: your appointment is tomorrow at 3pm.", "scheduled_at": "2026-10-12T15:00:00.000Z", "run_at": "2026-10-12T15:00:00.000Z", "status": "queued", "batch_id": null } ], "has_more": false, "next_cursor": null } ``` The list holds messages that are still waiting, soonest first. A message leaves it as soon as it is sent, dropped or canceled. There is one page of up to 1,000 messages; `has_more` is always `false`. The list covers the whole account, whichever key you call it with. Recipients of a scheduled [batch](/docs/broadcasts) are not included; a batch lists its own. A restricted key needs the `messages:read` scope. The console shows the same list under Scheduled, with a Cancel button on each row. ## Retrieving one `GET /v1/scheduled_messages/{id}` returns one pending scheduled message. It answers `404` once the message has been sent or canceled: from then on it is a message, and you read it with `GET /v1/messages`. ## Canceling `DELETE /v1/scheduled_messages/{id}` ```bash curl -X DELETE https://api.jointextkit.com/v1/scheduled_messages/job_1791817200000a1B2c3D4e5 \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" ``` ```json { "id": "job_1791817200000a1B2c3D4e5", "object": "scheduled_message", "status": "canceled" } ``` A cancel either wins or loses as a whole: a message that is already being sent cannot be canceled, and a canceled message is never sent. A restricted key needs the `messages:send` scope. To send one message to many people later, schedule a [batch](/docs/broadcasts) instead. ## Errors | Status | Code | When | | --- | --- | --- | | `400` | `invalid_request` | `scheduled_at` is not a future ISO timestamp or epoch ms, or is more than 30 days ahead (`param` is `scheduled_at`). Also a bad `to`, `from`, `body` or `customer_id`. The body may be 1 to 1600 characters. | | `400` | `mms_not_enabled` | The request has a `media` field. | | `403` | `forbidden` | `from` is not a number on your account. | | `403` | `insufficient_scope` | A restricted key without the scope the call needs. | | `404` | `not_found` | On cancel: no pending scheduled message has that id. It may have been sent or canceled already. | | `409` | `idempotency_conflict` | The `Idempotency-Key` was already used with a different payload. | | `409` | `idempotency_in_progress` | The first request with this key is still running. Retry after `Retry-After` seconds; the SDKs do. | Every code is explained in [Errors](/docs/errors#codes). --- # Recurring schedules A schedule sends one message to one number on a repeating wall-clock time: every morning at 8:00, every Monday and Friday at 9:00, the 1st of each month. You create it once; TextKit sends each occurrence. ```bash curl -X POST https://api.jointextkit.com/v1/schedules \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155550132", "body": "Good morning. You have got this.", "repeat": { "frequency": "daily", "time": "08:00", "timezone": "America/New_York" } }' ``` | Parameter | Type | Notes | | --- | --- | --- | | `to` | string | Destination number (E.164, US/Canada). | | `from` | string | A number you own. Optional when the account has one number for the key's mode, like [POST /v1/messages](/docs/messages#default-sender). | | `body` | string | Up to 1600 characters, the same as a normal send. | | `repeat.frequency` | string | `daily`, `weekly` or `monthly`. | | `repeat.time` | string | 24-hour local time, `"HH:MM"`. | | `repeat.timezone` | string | IANA time zone, e.g. `America/New_York`. | | `repeat.days_of_week` | string[] | Weekly only, required: one or more of `sun`, `mon`, `tue`, `wed`, `thu`, `fri`, `sat`. | | `repeat.day_of_month` | integer | Monthly only, required: 1 to 31. In a shorter month it runs on the last day. | | `start_date` | string | Optional. First local date it may run, `YYYY-MM-DD`. Defaults to today. | | `end_date` | string | Optional. Last local date it may run (inclusive). | | `max_occurrences` | integer | Optional. The schedule completes after this many messages have been sent. | | `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)). | | `quiet_hours` | string | Optional. `defer` (the default) holds an occurrence that comes due in the recipient's quiet hours until their 8:00am; `block` records it as failed with `quiet_hours` instead. | Returns `201` with a Schedule object. Pass an `Idempotency-Key` header to make a retried create safe. ## The Schedule object ```json { "id": "sch_a1B2c3D4e5F6g7H8", "object": "schedule", "to": "+14155550132", "from": "+15005550100", "body": "Good morning. You have got this.", "repeat": { "frequency": "daily", "time": "08:00", "timezone": "America/New_York" }, "start_date": null, "end_date": null, "max_occurrences": null, "active": true, "status": "active", "paused_reason": null, "next_run_at": "2026-10-06T12:00:00.000Z", "last_run_at": "2026-10-05T12:00:00.000Z", "last_status": "sent", "last_error": null, "last_message_id": "msg_a1B2c3D4e5F6g7H8", "counts": { "sent": 1, "skipped": 0, "failed": 0 }, "test": true, "created_at": "2026-10-05T11:42:10.000Z", "updated_at": "2026-10-05T12:00:02.000Z" } ``` | Field | Notes | | --- | --- | | `status` | `active`, `paused` or `completed` (`end_date` passed or `max_occurrences` reached). `active` is the same thing as a boolean. | | `paused_reason` | `manual` (you paused it), `recipient_opted_out`, `repeated_failures` or `sender_unavailable`. `null` unless paused. | | `next_run_at` | The next occurrence, in UTC. `null` unless active. | | `last_status` | `sent`, `skipped_opted_out`, `skipped_missed`, `deferred` (held for quiet hours and sent at the recipient's 8:00am) or `failed`. `last_error` says why for a skip or a failure. | | `counts` | Messages sent, occurrences skipped (opted out or missed), occurrences that failed. | | `test` | `true` for a schedule made with a test key. It only ever sends in the [sandbox](/docs/sandbox). | | `recent_occurrences` | Only on `GET /v1/schedules/{id}`: the last 10 occurrences with `date`, `due_at`, `status`, `message_id` and `error`. | ## Every occurrence is a normal send When an occurrence is due, the message goes through exactly what a request to `POST /v1/messages` goes through at that moment: the opt-out list, sender registration, your quota and [spend limit](/docs/spend-limits), content screening. Nothing is reserved in advance, and a refused occurrence costs nothing. Each occurrence is an ordinary message with `schedule_id` set. It appears in `GET /v1/messages` (filter with `?schedule_id=sch_...`), in the console, and fires the usual `message.sent` and `message.delivered` events. ## Time zones and daylight saving `time` is a wall-clock time in `timezone`. 08:00 in New York stays 08:00 in New York all year; the UTC time in `next_run_at` moves by an hour when the clocks change. - **A time that does not exist** (clocks jump from 02:00 to 03:00, and the schedule says 02:30): that day it runs the same distance past the jump, at 03:30. It is not skipped. - **A time that happens twice** (clocks fall back and 01:30 occurs twice): it runs once, the first time. A schedule sends at most one message per local calendar day, whatever you edit. ## What happens when - **The recipient has opted out.** The occurrence is skipped and recorded (`last_status: "skipped_opted_out"`), and nothing is sent. After 3 skipped occurrences in a row the schedule pauses itself with `paused_reason: "recipient_opted_out"`. If they opt back in, resume it. - **An occurrence fails** (quota reached, sender not registered, spend limit, not enough [credits](/docs/credits), carrier error): it is recorded as `failed` with the error code and the schedule carries on. Credits are drawn when an occurrence fires, not when the schedule is created; one the balance cannot cover fails with `insufficient_credits` and is not charged. After 5 failures in a row it pauses itself (`repeated_failures`). If the sending number was released it pauses at once (`sender_unavailable`). - **We miss an occurrence.** If our scheduler reaches an occurrence more than 1 hour late, the message is **not** sent late; it is recorded as `skipped_missed`. After a longer outage only the most recent occurrence is considered, and only if it is less than 1 hour old. You never get a burst of catch-up messages. - **Exactly once.** Each occurrence is claimed before it is sent, so it cannot be sent twice. Two [webhook events](/docs/webhooks) report this: `schedule.occurrence_skipped` (`schedule_id`, `to`, `occurrence_date`, `due_at`, `reason`) and `schedule.paused` (`schedule_id`, `to`, `reason`). ## Pause, resume, change, delete ```bash # Pause curl -X PATCH https://api.jointextkit.com/v1/schedules/sch_a1B2c3D4e5F6g7H8 \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "active": false }' ``` - `PATCH /v1/schedules/{id}` with `{ "active": false }` pauses and `{ "active": true }` resumes. Resuming starts from now: nothing that came due while paused is sent. - The same call changes `to`, `from`, `body`, `repeat`, `start_date`, `end_date` or `max_occurrences`. Send the whole `repeat` object. - `GET /v1/schedules` lists them, `GET /v1/schedules/{id}` returns one. - `DELETE /v1/schedules/{id}` stops it for good. Messages it already sent stay in your message log. In the console, schedules are on the [Scheduled](/console/scheduled) page with their next run and Pause, Resume and Delete. ## With the SDKs ```javascript const schedule = await sms.schedules.create({ to: '+14155550132', body: 'Good morning. You have got this.', repeat: { frequency: 'daily', time: '08:00', timezone: 'America/New_York' }, }); await sms.schedules.pause(schedule.id); await sms.messages.list({ scheduleId: schedule.id }); // what it has sent ``` ```python schedule = client.schedules.create( to="+14155550132", body="Good morning. You have got this.", repeat={"frequency": "daily", "time": "08:00", "timezone": "America/New_York"}, ) client.schedules.pause(schedule["id"]) ``` ## Limits - 25 active schedules per account, 100 stored in total. - At most one message per local day per schedule (the shortest frequency is daily). - `body` up to 1600 characters. - Key scopes: `messages:send` to create, change and delete; `messages:read` to list and retrieve. ## What is stored A schedule stores the recipient's number and the message text until you delete it, so it can send the next occurrence. A record of each occurrence (date, outcome, message id; never the text) is kept for the last 90 occurrences. Deleting the schedule deletes both. The messages it sent are ordinary messages and follow normal message retention. --- # Automations Your app says what happened. TextKit decides what to text and when. ```js await sms.events.track({ user_id: "123", phone: "+14155550132", event: "trial_started" }); ``` A **flow** starts on one event and runs a list of steps for that person: ``` trial_started -> Wait 1 hour -> Send SMS -> Wait until subscription_created arrives: end not in 3 days: continue -> Send SMS (reminder) ``` Build flows in [Console → Automations](/console/automations) or with the API below. Six templates ship in the console: a welcome series, this trial reminder, an abandoned-onboarding nudge, an [abandoned cart](/docs/abandoned-cart) text, and an [order confirmation and a shipping update](/docs/abandoned-cart#order-and-shipping-texts). ## Track an event ```bash curl -X POST https://api.jointextkit.com/v1/track \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event":"trial_started","user_id":"123","phone":"+14155550132","properties":{"plan":"pro"}}' ``` | Field | | | --- | --- | | `event` | Required. Letters, digits, and `_ . : -`, up to 64 characters. | | `phone` | The person's number in E.164. | | `user_id` | Your own id for the person, up to 128 characters. | | `properties` | Up to 20 values (text, number, true/false). Usable as merge fields and in conditions. | Pass `phone`, `user_id`, or both. **A call with both links the two**, so later events need only `user_id`. An event for a `user_id` that has never been linked is stored, starts nothing, and answers with `"phone": null`. The response lists what the event did: ```json { "id": "tev_...", "object": "tracked_event", "event": "trial_started", "user_id": "123", "phone": "+14155550132", "test": false, "runs_started": ["run_..."], "runs_notified": 0 } ``` `Idempotency-Key` works as on `POST /v1/messages`. The limit is 100 calls per 10 seconds per key. `GET /v1/events` is a different thing: the log of [webhook events](/docs/webhooks) TextKit sends you. ## Steps | Step | Fields | | --- | --- | | `send_sms` | `body`, optional `from` (defaults to the flow's number) | | `wait` | `seconds` (1 minute to 30 days) | | `wait_for_event` | `event`, `timeout_seconds`, `on_received`, `on_timeout` | | `condition` | `check`, `on_met`, `on_not_met` | | `end` | none | A branch (`on_received`, `on_timeout`, `on_met`, `on_not_met`) is `"continue"`, `"end"`, or `"goto:"`. A `goto` may only point at a **later** step, so a flow can never loop and a run sends at most one message per send step. A `check` is one of: - `{ "kind": "event_received", "event": "onboarding_completed" }`: true if the person sent that event since the run started. - `{ "kind": "property", "property": "plan", "op": "eq", "value": "pro" }`: a property of the event that started the run. - `{ "kind": "contact_field", "field": "company", "op": "exists" }`: the [contact](/docs/contacts) with that number (`name`, `tag`, or a custom field). Operators: `eq`, `neq`, `contains`, `gt`, `lt`, `exists`, `not_exists`. `wait_for_event` counts the event if it arrived **any time since the run started**, not only after the step began: someone who subscribed during an earlier wait has subscribed. Message bodies take [merge fields](/docs/broadcasts): every event property by name (`{{plan}}`), plus `{{first_name}}`, `{{name}}`, `{{phone}}` and `{{field:company}}` from the contact. A field with no value sends as nothing; a message that merges to nothing is skipped. ## Create and activate ```bash curl -X POST https://api.jointextkit.com/v1/automations \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Trial reminder", "trigger": { "event": "trial_started" }, "steps": [ { "type": "wait", "seconds": 3600 }, { "type": "send_sms", "body": "Your trial is live, {{first_name}}. Reply STOP to opt out." }, { "type": "wait_for_event", "event": "subscription_created", "timeout_seconds": 259200, "on_received": "end", "on_timeout": "continue" }, { "type": "send_sms", "body": "Your trial ends soon. Pick a plan to keep your account." } ] }' ``` A flow is created as a `draft`. The response carries `issues`: everything that stops it from running, each naming its step. `POST /v1/automations/{id}/activate` turns it on (and answers 400 with the same `issues` if it is not ready); `POST /v1/automations/{id}/pause` stops new runs and holds the ones in progress where they are. Activating again resumes them within 5 minutes. A trigger can filter on properties: `"trigger": { "event": "trial_started", "filters": [{ "property": "plan", "op": "eq", "value": "pro" }] }`. | Endpoint | | | --- | --- | | `GET /v1/automations` | All flows, with run counts | | `POST /v1/automations` | Create a draft | | `GET` / `PATCH` / `DELETE /v1/automations/{id}` | Read, edit, delete | | `POST /v1/automations/{id}/activate` · `/pause` | Turn on, hold | | `GET /v1/automations/{id}/runs` | Runs, newest first | | `GET` / `DELETE /v1/automations/{id}/runs/{run_id}` | One run with its timeline; cancel it | All of it needs the `automations` scope on a restricted key. ## Sandbox and live The flow's `from` number decides. A flow sending from a sandbox number listens only to events tracked with a **test key**, and behaves like any sandbox send: [magic numbers](/docs/sandbox) work, nothing is billed, and only your own verified phone gets a real text. A flow sending from a live number listens only to **live-key** events. So the same event name can drive a sandbox copy and a live copy of a flow without either seeing the other's traffic. ## What a run guarantees - **One run per person per flow.** A repeat of the trigger while a run is in progress is ignored (counted as `triggers_skipped`). Once the run ends, the next trigger starts a new one. - **No duplicate texts.** Each send step is attempted at most once. If our worker fails mid-send, the step is recorded as `send_unconfirmed` and not retried. - **Edits never change a run in flight.** Saving an active flow publishes a new version; runs finish on the version they started with. - **Opt-outs win.** Every message goes through the same checks as `POST /v1/messages`: the [opt-out list](/docs/opt-out), content screening, quotas and your [spend limit](/docs/spend-limits). A run whose recipient has opted out ends (`opted_out`) the next time it wakes, before anything is sent. Set `topic_id` on a flow to also skip people unsubscribed from that topic. - **A failed send does not stop the run.** It is recorded on the run's timeline with the error code and the run continues. Transient carrier failures are retried by the normal [message retry](/docs/errors) path. - **Waits are checked every minute.** A step due at 14:00:20 runs by 14:01. A first step that sends goes out with the track call itself. ## Runs ```json { "id": "run_...", "object": "automation_run", "automation_id": "auto_...", "status": "waiting", "to": "+14155550132", "step": 2, "waiting_for_event": "subscription_created", "waiting_until": "2026-10-07T15:00:00.000Z", "end_reason": null, "messages_sent": 1, "timeline": [ { "at": "...", "type": "started", "detail": "trial_started" }, { "at": "...", "type": "sent", "step_id": "s2", "message_id": "msg_..." }, { "at": "...", "type": "waiting_for_event", "step_id": "s3", "detail": "subscription_created" } ] } ``` `status` is `running`, `waiting`, `completed`, or `failed`. `end_reason` says why: `finished`, `end_step`, `branch_end` (completed); `opted_out`, `canceled`, `flow_deleted`, `expired` (a run older than 90 days), `error` (failed). Three [webhook events](/docs/webhooks) follow a run: `automation.run.started`, `automation.run.completed` and `automation.run.failed`, each with `automation_id`, `run_id`, `to` and, at the end, `reason`, `messages_sent` and `messages_failed`. ## Limits and retention - 20 steps per flow, 50 flows per account, 20 active at once, 5 trigger filters. - Waits and timeouts: 1 minute to 30 days. A run ends after 90 days whatever it is waiting for. - Tracked events are kept for 30 days. A run is kept for 30 days after it ends. The `user_id` to phone links are kept until you close your account, or until you unlink one. ## Unlink a user To honour a deletion request for one person, forget the number linked to their `user_id`: ```bash curl -X DELETE https://api.jointextkit.com/v1/automations/identities/123 \ -H "Authorization: Bearer $TEXTKIT_API_KEY" ``` Returns `{ "object": "automation_identity", "user_id": "123", "unlinked": true }`, or `404` when nothing is linked. Later events with only that `user_id` start nothing until a call with both `user_id` and `phone` links them again. Runs, tracked events and the number's consent records are not changed; it is audit-logged as `automation.identity_unlinked`. Deleting the person's contact and consent records is a separate request: see the [Data Retention](/data-retention) page. --- # Abandoned cart texts A shopper starts checkout and leaves. An hour later, if no order came in, they get one text with their cart link: ``` checkout_started -> Wait until order_placed arrives: end not in 1 hour: continue -> Send SMS ``` ``` Acme: you left something in your cart. Finish your order here: https://acme.com/checkouts/c/4b1e... Reply STOP to opt out. ``` This flow is the **Abandoned cart** template in [Console → Automations](/console/automations). It runs on two [tracked events](/docs/automations#track-an-event) from your store: | Event | When | Properties | | --- | --- | --- | | `checkout_started` | A shopper with a phone number reaches checkout | `checkout_url` (the link in the text) | | `order_placed` | The order goes through | none needed | A connected Shopify store sends them for you. Otherwise send them from Shopify Flow, Klaviyo, or your own code. Each way is below. ## Before you start - **A number that can text the public:** a registered local number or a verified toll-free number. See [Compliance](/docs/compliance). - **Opt-ins on file.** A cart reminder is a marketing text, so it goes only to shoppers whose opt-in is recorded with TextKit. Shoppers who tick the SMS box at your Shopify checkout come in when you connect Shopify in [Console → Contacts → Integrations](/console/contacts/integrations). Subscribers from another tool come in as a CSV with consent columns ([Contacts](/docs/contacts)). Everyone else is skipped, and the run logs why. - **An API key** for the tool that calls TextKit. A [restricted key](/docs/authentication) is enough: the `automations` scope for `POST /v1/track`, or `messages:send` for `POST /v1/messages`. ## Connected Shopify store 1. Connect the store in [Console → Contacts → Integrations](/console/contacts/integrations). The store's app needs the `read_customers` and `read_orders` scopes. A store connected before cart reminders existed: reconnect it once. 2. In Console → Automations, create a flow from the **Abandoned cart** template, pick your sending number, edit the text, and activate it. That's all. Every 10 minutes TextKit checks the store for abandoned checkouts and new orders and sends the two events, each checkout once. Shoppers who ticked the SMS box at checkout are recorded as opted in on the way. A checkout becomes an event only for a shopper who agreed to texts (that box, or an opt-in already on file); nobody else gets a reminder. The checks run only while a flow that starts on `checkout_started` is active. If Shopify refuses them, the integration's card in the console says why. ## Shopify Flow Shopify Flow is Shopify's free automation app. Its **Send HTTP request** action is on the Grow, Advanced and Plus plans, not Basic. 1. In Console → Automations, create a flow from the **Abandoned cart** template, pick your sending number, edit the text, and activate it. 2. In Shopify admin, open **Flow** → **Create workflow**. 3. Trigger: **Customer abandons checkout**. 4. Action: **Send HTTP request**. - Method: `POST` - URL: `https://api.jointextkit.com/v1/track` - Headers: `Authorization`: `Bearer YOUR_KEY` (store the key as a Flow secret), and `Content-Type`: `application/json` - Body: ```liquid { "event": "checkout_started", "phone": "{{ customer.phone }}", "properties": { "checkout_url": "{{ abandonment.abandonedCheckoutPayload.abandonedCheckoutUrl }}" } } ``` 5. Turn the workflow on. Pick the two variables from Flow's variable list if your store's names differ. Shopify decides when a checkout counts as abandoned, so the text goes out about an hour after that. To stop a text for a shopper who orders in that hour, add a second workflow: trigger **Order created**, the same request with `"event": "order_placed"` and `"phone": "{{ order.phone }}"`. ## Klaviyo Klaviyo's own **Abandoned Checkout** flow decides when to send and skips shoppers who ordered. TextKit sends the text, so you don't need the template here. 1. Open the flow that starts on **Checkout Started**. 2. After its time delay, add a **Webhook** action: - URL: `https://api.jointextkit.com/v1/messages` - Headers: `Authorization`: `Bearer YOUR_KEY`, and `Content-Type`: `application/json` - Body: ```json { "from": "+18335550100", "to": "{{ person.phone_number }}", "body": "Acme: you left something in your cart. Finish your order here: {{ event.extra.checkout_url }} Reply STOP to opt out.", "category": "marketing" } ``` 3. Set the action to **Live**. Klaviyo needs two-step authentication on your login before it sends webhooks. Open **View profile and event variables** in the webhook editor to check the checkout link's name in your account. ## Your own code Send the two events when they happen: ```bash curl -X POST https://api.jointextkit.com/v1/track \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event":"checkout_started","phone":"+14155550132","properties":{"checkout_url":"https://acme.com/checkouts/c/4b1e"}}' curl -X POST https://api.jointextkit.com/v1/track \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event":"order_placed","phone":"+14155550132"}' ``` A shopper who starts checkout again while a run is waiting stays in that run: one text per checkout, not one per page view. ## Order and shipping texts A connected Shopify store also sends two events for its orders: | Event | When | Properties | | --- | --- | --- | | `order_placed` | A new order with a phone number | `order_name` (#1001), `order_url` (the order status page), `total`, `currency`, `items`, `item` | | `order_shipped` | A shipment gets a tracking number or link | `order_name`, `tracking_number`, `tracking_url`, `tracking_company` | Two templates in Console → Automations start on them: ``` Acme: thanks for your order #1001. Check its status here: https://acme.com/... Reply STOP to opt out. Acme: your order #1001 has shipped. Track it here: https://www.ups.com/... Reply STOP to opt out. ``` 1. Connect the store as above. Order texts need `read_orders`; shipping texts from a store connected with one click also use `read_fulfillments`. 2. Create a flow from **Order confirmation** or **Shipping update**, pick your sending number, edit the text, and activate it. A store connected with one click sends each event within seconds. A store connected with its own token is checked every 10 minutes. Each order and each shipment counts once. Orders older than a day and shipments older than a week never start a flow. When a shipment has no tracking link, `tracking_url` is the order status page. These texts go only to shoppers whose opt-in is on file with TextKit: shoppers who ticked the SMS box at your checkout, or who opted in any other way ([Contacts](/docs/contacts)). Everyone else is skipped, and the run logs why. Your own verified numbers always get them, so you can test on yourself. The same holds when you send `order_placed` or `order_shipped` yourself with `POST /v1/track`. ## Check it Each run has a timeline in Console → Automations: the event that started it, the wait, and the text, or why it was held (quiet hours) or skipped (no opt-in, opted out). Try the flow with a [test key](/docs/sandbox) and your own number before you turn it on for shoppers. --- # Auto-replies & office hours Each number can answer inbound texts automatically: an away message, an out-of-office, or a first-touch acknowledgement. ## Configuration Auto-replies are set in the console, not through the API. Open [Console → Numbers](/console/numbers), expand **Auto-reply** under a number, and save. Each number has its own settings, and only a number that is currently on your account can have one. | Setting | Notes | | --- | --- | | `enabled` | Turns the reply on. An enabled auto-reply needs a message; saving it with a blank one is refused. | | `message` | The reply text, at most 320 characters. Anything longer is cut at 320. | | `officeHours.tz` | An IANA time zone such as `America/New_York`. The console fills in your browser's zone. | | `officeHours.days` | The days you are open, as numbers: 0 is Sunday, 6 is Saturday. The console starts with Monday to Friday. | | `officeHours.start`, `officeHours.end` | Opening and closing time as 24-hour `HH:MM`. The console starts with `09:00` and `17:00`. | | `officeHours.mode` | `always` or `after_hours`, described below. | - `always`: reply to every eligible inbound. The hours are ignored. This is also what you get with no office hours at all. - `after_hours`: the out-of-office pattern: reply only OUTSIDE the hours. Ticking "Only outside office hours" in the console saves this mode. ### How office hours are read You are open when the current day, in your time zone, is one of `days` and the time is at or after `start` and before `end`. A day that is not listed is closed all day, so with Monday to Friday hours a Saturday text always gets the reply. The window cannot cross midnight: if `end` is earlier than `start` you are never open, and `after_hours` replies around the clock. ### Screening and the audit log The text is checked against the content policy when you save an enabled auto-reply, and a blocked text is refused with the reason. It is checked once, at save time, not on every reply. Each save is recorded in the [audit log](/docs/audit-logs) as `settings.updated` with target type `auto_reply`: whether it is enabled and which hours mode it uses, never the text. ## Guardrails (not configurable) These are checked in this order on every inbound text: - STOP / START / HELP keywords always win; the auto-reply never answers them. That covers every [keyword](/docs/opt-out#keywords) and a plain-English request to stop. Those messages get the keyword reply instead, or none. - Verification codes are never answered. A text counts as one when it is only a 4 to 8 digit number, when it has a 4 to 8 digit number together with one of the words code, verification, verify, otp, passcode or pin, or when it contains a code in the form `G-123456`. - Opted-out counterparties are never texted. - One auto-reply per conversation per 4 hours, claimed atomically; two simultaneous inbound messages cannot double-send. The 4-hour window belongs to the [conversation](/docs/inbox): one of your numbers and one other party. The same person texting two of your numbers can get a reply from each. The window is claimed before the reply is sent, so a reply that fails at the carrier still uses it. ## The reply as a message An auto-reply is an ordinary outbound message from the number that received the text. It appears in the inbox thread and in `GET /v1/messages`. - It is not billed. Its `price` is 0 with the line "Automatic reply (not billed)". - It does not count against your daily or monthly message quota. - It has no `sent_by`. ## Events The inbound text emits `message.received` first. The reply then emits `message.sent` with two extra fields: ```json { "type": "message.sent", "data": { "message_id": "msg_a1B2c3D4e5F6g7H8", "to": "+14155550132", "from": "+15005550100", "body": "Thanks for texting! We are closed and will reply in the morning.", "auto_reply": true, "kind": "auto_reply" } } ``` STOP and HELP confirmations carry `auto_reply: true` as well, with `kind` set to `keyword_reply`. Check `kind` to tell the two apart. On a live number the event also has a `carrier_reference`. If a live reply cannot be handed to the carrier, the message is stored as `failed` and you get `message.failed` with `code` `carrier_rejected`, a `reason`, the `failure` object, and the same `auto_reply` and `kind` fields. Event delivery is covered in [Webhooks](/docs/webhooks). ## Testing Works identically in sandbox: simulate an inbound with `POST /v1/test/inbound` and the reply appears in the thread with an `auto_reply: true` marker on its event. ```bash curl -X POST https://api.jointextkit.com/v1/test/inbound \ -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+14155550132", "body": "Are you open?" }' ``` The call returns `201` with the inbound message, not the reply. Find the reply in `GET /v1/events` or in `GET /v1/messages?number=%2B14155550132`. A sandbox reply is recorded with status `sent` and `test: true`, and is never transmitted. Send a second text within 4 hours and no reply comes back: that is the cooldown. The endpoint takes test keys only; a live key gets `403 test_mode_only`. Neither SDK has a call for it or for auto-reply settings. --- # Number porting You can bring a number you already own. Porting is a carrier-side process with paperwork and multi-day timelines, so it runs as a tracked request rather than an instant API call. ## What we need - The number, your current carrier, and the account number with them - The last 4 of your account PIN (if your carrier uses one) - The name of the person authorized to approve the transfer Submit from Console → Numbers → Bring your numbers → Port one number (admins only). ## Timeline | Status | Meaning | | --- | --- | | `requested` | We received your request. | | `submitted` | Filed with the carrier. | | `foc_set` | The carrier set a Firm Order Commitment date. | | `complete` | The number is live on TextKit. | | `rejected` | The carrier rejected it; the note says why (usually a detail mismatch). | The status timeline is visible in the console; keep the old service active until the port completes. ## Port many at once Console → Numbers → Bring your numbers → Port from a CSV takes a CSV: either one column of numbers (the form supplies carrier, account number, PIN and authorized name for all of them) or a header row with `number`, `carrier`, `account number`, `pin`, `authorized name` columns for numbers spread across old carriers. Every row is checked before anything is filed: | Row status | Meaning | | --- | --- | | ready | Will be filed. | | not a valid number | Not a US/Canada number. | | duplicate | Appears earlier in the file. | | already yours | Already on your TextKit account. | | port already open | A request for it is in flight (rejected ones can be re-filed). | | missing details | Carrier, account number or authorized name missing. | Only ready rows are filed. You can choose a pool and tags for the numbers to join when their port completes. Accounts with live access can run a portability check first: current carrier and whether the number can port, per number. ## Moving from Twilio, Telnyx or Plivo Console → Numbers → Bring your numbers reads your account with the credentials you paste (Twilio Account SID + Auth Token, Telnyx API key, Plivo Auth ID + Auth Token). They are used once, for read-only requests, and never stored. You get a plan to review: - **Numbers to port**, each marked will port / already on TextKit / port already open / not SMS-enabled / not US-Canada. - **Pools to recreate** from Twilio Messaging Services, Telnyx messaging profiles, or Plivo Powerpacks. Numbers join them as their ports complete. - **Webhook URLs found** (inbound and status callbacks), offered as TextKit webhook endpoints. Your handler has to accept TextKit's JSON events, not the old provider's callback format. Opt-out lists aren't exposed by any of the three APIs; TextKit enforces STOP/START itself, and you can import an existing suppression list with `POST /v1/consent/import`. Running the plan only creates TextKit-side things (port requests, pools, webhooks); nothing changes at the old provider until each carrier port completes. --- # Opt-out, consent & TCPA Honouring opt-out is a legal obligation, not a feature. TextKit enforces it on your behalf for every send, keeps an append-only consent ledger you can export, and gives you the events to keep your own systems in sync. There is nothing to configure; every account gets this by default. ## Revocation in plain English The FCC's April 2025 revocation rule requires honouring a revocation expressed by any reasonable means, not just the standard keywords. TextKit detects revocation in three tiers, in order: | Tier | Detects | Example | | --- | --- | --- | | Keywords | The exact CTIA keywords | "STOP" | | Phrases | Common plain-English forms, deterministically | "please stop texting me", "remove me from your list" | | Intent | Everything else revocation-shaped, with a confidence score | "i would rather you didn't message this number" | All three record the opt-out, send the single confirmation reply, block future sends, and emit `message.opted_out` with a `method` field (`keyword`, `phrase`, or `ai`) so you can see how it was detected. A revocation is never answered by an auto-reply. ## Keywords | Reply | Effect | | --- | --- | | `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`, `OPTOUT`, `REVOKE`, `ARRET`, also spaced or hyphenated (`S T O P`, `STOP ALL`), common misspellings (`STP`, `STIP`, `STOPP`, `SSTOP`) and `SPAM` | Opts the number out of **your** messages. We send one confirmation and nothing more. | | `START`, `UNSTOP`, `YES` | Opts back in. Someone who was opted out gets one "you are resubscribed" reply; an ordinary "yes" from someone who never opted out gets no reply. | | `HELP`, `INFO`, `AIDE` | Sends your help reply, even to an opted-out number, and emits `message.help_requested`. | A second `STOP` from a number that is already opted out changes nothing: no second confirmation, no second event. ### Replies speak for your business The confirmation, resubscribe and help replies name **you**, not TextKit, because the person texting has never heard of us: - A number linked to a [registration](/docs/compliance) sends exactly the HELP, STOP and opt-in messages that registration filed with the carriers, including your support contact. - A number assigned to a [customer](/docs/customers) is answered in that customer's name. - Otherwise the reply uses your account name. An account name that is an email address is never shown; the reply says "this number" instead. Keyword matching is exact: the message has to *be* the keyword, ignoring case, surrounding whitespace and punctuation. "please stop by tomorrow" is an ordinary message; the phrase tier only fires on messaging-directed forms like "stop texting me". ## One suppression list per account Every account has a single suppression list, and every ordinary message passes through it before it can reach a carrier. It is account-wide on purpose: an opt-out applies to **all** your numbers, sender pools and [customers](/docs/customers), so a recipient who texts STOP to one of your numbers cannot be texted from another. | Path | How it honours the list | | --- | --- | | `POST /v1/messages`, console sends | Checked first; refused with `recipient_opted_out`. | | Batches and broadcasts | Opted-out recipients are skipped and counted, then re-checked at send time. | | Scheduled sends, automations | Re-checked when the send actually runs, not when it was scheduled. | | Automatic carrier retries | Re-checked before every attempt. | | Auto-replies | Never sent to an opted-out number. | | STOP / START / HELP replies | Exempt: these are the replies the rules require. | | `POST /v1/verify` | Checked first; refused with `recipient_opted_out` (see below). | The list fails closed. If we cannot read it, the send is not attempted: you get `503` with `Retry-After`, nothing is sent and nothing is billed. We would rather make you retry than text someone who said stop. You manage the same list through the API above: read it (`GET /v1/consent`), add to it (`POST /v1/consent/{phone}`, or `/v1/consent/import` for a list from another provider) and export it (`/v1/consent/export`). An opt-out the person gave themselves (a STOP or plain-words reply, or one imported from another provider) is permanent: only they can lift it, by texting START (or your opt-in keyword) to one of your numbers. `POST /v1/consent/{phone}` with `"status": "opted_in"` answers `409 invalid_request` and changes nothing. Recording an opt-out of your own over theirs keeps theirs. An opt-out you recorded yourself (`POST /v1/consent/{phone}` or the console), or one from a report through our form (we cannot confirm who filed it), can be undone with `proof` of a new opt-in, `collected_at` at or after the opt-out; otherwise `409 invalid_request` (`param`: `proof` or `proof.collected_at`). Once the person texts STOP themselves, it is theirs and permanent. In the console, only an admin can (Compliance, **Opt back in**). The list is yours alone. An opt-out silences **your** traffic to that number, not everyone's. Someone who unsubscribes from one sender does not stop receiving login codes from another; that would turn an unsubscribe into an account lockout. ## What gets blocked `POST /v1/messages` to an opted-out number returns: ```json { "error": { "code": "recipient_opted_out", "message": "This recipient opted out of your messages on Oct 5, 2026 at 8:42 AM UTC by replying STOP. Nothing was sent and you were not charged. They can opt back in by texting START to your number.", "param": "to", "opted_out_at": "2026-10-05T08:42:00.000Z", "opted_out_via": "sms_keyword", "opted_out_method": "keyword", "failure": { "code": "opted_out", "title": "Recipient opted out", "explanation": "...", "action": "Recommended: ..." } } } ``` The status is `403`. Nothing leaves our network and nothing is billed. You do not have to check consent before sending: send, and handle this one code. A refused send also leaves a trace, so "why wasn't this delivered?" is answered wherever you look: - a `message.blocked` event (and webhook) with `to`, `from`, `reason: "opted_out"` and the same `opted_out_*` fields; - a `blocked_send` entry in the number's consent history (at most one a day per number, so a retry loop cannot bury the opt-out itself); - `blocked_sends` and `last_blocked_at` on `GET /v1/consent/{phone}`. Broadcast recipients who opted out are skipped and counted, never messaged. Scheduled sends re-check consent at send time. **One-time codes are blocked too.** `POST /v1/verify` to a number that opted out answers `403 recipient_opted_out`: nothing is sent and nothing is billed. The same applies when the person texted STOP to the number the code would come from. They text START to get codes again. ## The consent ledger Every consent change is appended to a per-number history that is never deleted: opt-outs (with the detected text and method), opt-ins, imports, API changes, and blocked sends. That history is your TCPA audit trail. ```bash # Current state + full history for one number curl https://api.jointextkit.com/v1/consent/+14155550132 \ -H "Authorization: Bearer $TEXTKIT_API_KEY" # Set consent from your own system (CRM sync, web form, support tool) curl -X POST https://api.jointextkit.com/v1/consent/+14155550132 \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "opted_out", "note": "revoked by email"}' # The whole suppression list, paginated curl "https://api.jointextkit.com/v1/consent?limit=100" \ -H "Authorization: Bearer $TEXTKIT_API_KEY" # Bulk import (up to 500 per request), e.g. when migrating from another provider curl -X POST https://api.jointextkit.com/v1/consent/import \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"phone_numbers": ["+14155550132", "+16155550176"]}' # Export everything as CSV curl https://api.jointextkit.com/v1/consent/export \ -H "Authorization: Bearer $TEXTKIT_API_KEY" ``` Opt-ins set via the API can carry `proof`: the source, time, page, exact disclosure wording, IP and user agent (see [Opt-in proof](/docs/compliance#opt-in-proof)), and `campaign_id`, the registration the person opted in to. `/v1/consent/export?type=ledger` exports every consent event with those proof columns. ```bash # An opt-in for one registration, with the evidence a marketing registration requires curl -X POST https://api.jointextkit.com/v1/consent/+14155550132 \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "opted_in", "campaign_id": "reg_a1B2c3D4e5F6", "proof": {"source": "web_form", "collected_at": "2026-10-01T15:04:05Z", "page_url": "https://acme.example/texts", "disclosure": "By checking this box you agree to receive marketing texts from Acme..."}}' ``` ## Consent is per registration Carriers treat consent as given to one business for one kind of message: the registration (use case) your number sends under. Someone who agreed to appointment reminders did not agree to promotions from the same business. So the consent API records an opt-in either for the whole account or for one registration, and the send check reads it like this: | Send | Opt-in it looks for | | --- | --- | | Marketing from a number linked to a registration | One recorded **for that registration**: `campaign_id` on the API, a `campaign_id` column in a contact import, a START (or YES) reply to one of its numbers, or a sign-up on its [hosted opt-in page](/docs/compliance#hosted-opt-in-page) | | Marketing from a number with no registration (the sandbox, a toll-free or Canadian number, a number not yet linked) | Any opt-in on file with the account, as before | | Transactional messages and one-time codes | Not checked | An opt-out is never narrower than the account: STOP to any of your numbers stops every registration, and clears every opt-in the number had. A keyword opt-in is scoped automatically to the registration of the number the person texted, with the inbound message recorded as its proof. `GET /v1/consent/{phone}` lists the registrations a number is opted in to as `campaigns`, and each history entry carries its `campaign_id`. Existing account-level opt-ins keep working for every transactional send and for marketing from an unregistered number; only marketing from a registered number now asks for the registration-scoped record. ## Proof for marketing opt-ins An opt-in recorded for a registration whose use case is marketing (or mixed) must carry `proof`: the `source`, `collected_at`, and the `page_url` for a web form or the inbound `message_id` for a keyword. Without it `POST /v1/consent/{phone}` answers `400 invalid_request` and records nothing; a contact import skips the row with the reason. Opt-ins for other registrations, and account-level opt-ins, accept the same fields and do not insist on them. Keyword opt-ins (START, JOIN, YES) and hosted opt-in page sign-ups record their proof by themselves. ## Re-confirmation after 30 days Some carriers require a fresh YES from anyone a marketing program has not texted in 30 days. We do that for you: see [re-confirmation](/docs/compliance-checks#re-confirmation-after-30-days). The [console Compliance page](/console/compliance) shows the same ledger with search, per-number history, import and export. ## Topics are narrower than an opt-out [Subscription topics](/docs/contacts#subscription-topics) let a person stop one kind of message (say Marketing) and keep another. They sit on top of everything on this page and never replace it: the opt-out check runs first on every send, an opted-out number gets nothing whatever its topics say, and subscribing a number to a topic does not opt it back in. Topic changes appear in the ledger as `topic_unsubscribe` and `topic_subscribe`; opt-in evidence stored by a [contact import](/docs/contacts#csv-import) appears as `import`. Neither changes `status`. ## Events | Event | When | | --- | --- | | `message.opted_out` | A revocation was detected (any tier) or set via API. Payload includes `method` and, for intent-tier detections, `confidence`. | | `message.opted_in` | A recipient opted in (START, the API, or your [hosted opt-in page](/docs/compliance#hosted-opt-in-page): `method: "hosted_page"` with `registration_id`). Carries `campaign_id` when the opt-in is for one registration. Keyword opt-ins carry `resubscribed`: `true` when the number had been opted out, and `reconfirmed: true` with `released_message_ids` when the reply answered a [re-confirmation](/docs/compliance-checks#re-confirmation-after-30-days). | | `message.help_requested` | A recipient texted HELP, INFO or AIDE. The help reply has already been sent. | | `message.blocked` | A send was refused because the recipient is opted out. Carries `to`, `from`, `reason` and when and how they opted out. | | `verification.sent_to_opted_out` | No longer sent: a code to an opted-out number is refused with `403 recipient_opted_out`. | Subscribe to these and mirror the state in your own database. You should never re-add a number that opted out, even though we block it. Imports do not emit per-number events; the ledger records each one. ## Testing it Opt-out works in the sandbox exactly as it does live, scoped to your account, so you can rehearse the whole path before going live. The phrase tier is deterministic, so it is testable too: ```bash curl -X POST https://api.jointextkit.com/v1/test/inbound \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from":"+15005550006","body":"please stop texting me"}' ``` The next send to that number returns 403, and `GET /v1/consent/+15005550006` shows the opt-out with `"method": "phrase"`. Send `START` to opt back in; the history keeps both entries. This page is educational, not legal advice. Your own counsel decides what your consent program needs; TextKit gives you enforcement and records by default. ## Bring your opt-outs A number that is new to your TextKit account is not safe to text just because nobody has replied STOP here yet. If you texted these people from another provider or tool, some of them opted out there, and that choice still applies. Opt-out lists are not shared between providers, so the list has to come with you. 1. Export the opt-out list from wherever you sent before. Any file with one phone number per row works: a single column with or without a header, or an export with other columns beside it. Numbers can be `+14155550123`, `14155550123`, `(415) 555-0123` or `415-555-0123`. 2. Upload it in Console → Numbers → Bring your numbers → **Bring your opt-outs**, or send it to the API: ```bash curl https://api.jointextkit.com/v1/consent/import \ -H "Authorization: Bearer $TEXTKIT_API_KEY" -H "Content-Type: application/json" \ -d '{"phone_numbers": ["+14155550123", "(212) 555-0148"]}' ``` Up to 500 numbers per request. Each one is recorded as opted out with the method `import` and is refused on every send from then on (`403 recipient_opted_out`). The console shows how many were imported and how many were skipped, with the reason: not a US or Canada number, a blank row, repeated in the file, or already on your list. **In a migration this step is required.** The import of numbers, pools and webhooks does not run until you have uploaded a list or confirmed, in the console, "I have no existing opt-outs to carry over". That confirmation is recorded in your [audit log](/docs/audit-logs) as `migration.opt_outs_attested`. Outside a migration you will see a reminder, not a block, when you import contacts or send a first broadcast with no opt-outs on file. --- # Compliance & registration Carriers only deliver business texting at volume from a registered **brand** (your business) and **campaign** (what you send, and how people opted in). Most registrations are rejected for the same few reasons: a privacy policy without the SMS clause, an opt-in form missing "Msg & data rates may apply", a pre-checked consent box. TextKit checks for all of them before anything is filed, writes the copy you are missing, and files the registration for you. Filing is part of the service. The registration fees themselves (brand, campaign and vetting fees) are billed separately from the per-segment rate; see [Pricing](/docs/pricing). Preparing a registration, running the checks and everything in the sandbox costs nothing, and it works with test keys exactly as it does with live keys. ## How it works 1. **You create a registration**: business name, website, the page where people opt in, what you will send (the use case), and 2 to 5 real examples of those texts. In the console you only type your website: we [fill in the rest](#fill-in-from-your-website) for you to review. 2. **We check your website and your examples** right away (details below). Every finding comes with the evidence (the URL and the text we found, or what we looked for) and, if it fails, the exact text to paste. 3. **You fix and recheck** until every check passes and the examples are yours. The registration is then `ready`. 4. **You submit.** The first time for a business, someone authorized to act for it signs a short [attestation](#business-verification), and we verify the business before anything is filed, usually within 1 business day. Then TextKit's compliance team files the brand and campaign with the carriers (`submitted`, then `in_review`). 5. **Carriers decide**, usually in 3 to 7 business days: `approved`, or `rejected` with a plain-language reason and the exact fix. Fix it, recheck, and submit again. Every registration answers five questions at all times, in its `guidance` field and on the [console Registration page](/console/registration): what happened, why, who acts next (`you`, `delivered`, `carriers`, or `nobody`), exactly what to do, and what happens after. ## Fill in from your website Give us the website and we suggest the rest of the registration: business name, a legal name if the site states one, the use case, a description, the opt-in, privacy and terms pages, support email, contact phone, address and industry. The console form does this when you click **Fill in from my website**; over the API it is one call: ```bash curl -X POST https://api.jointextkit.com/v1/registrations/prefill \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"website": "https://acmeplumbing.com"}' ``` ```json { "object": "registration_prefill", "reachable": true, "error": null, "fields": { "website": "https://acmeplumbing.com/", "business_name": "Acme Plumbing", "use_case": "account_notification", "description": "Licensed plumbers serving Springfield since 1998. Acme Plumbing sends account alerts and notifications to customers who have opted in. ...", "opt_in_url": "https://acmeplumbing.com/text-updates", "privacy_url": "https://acmeplumbing.com/privacy", "terms_url": "https://acmeplumbing.com/terms", "support_email": "help@acmeplumbing.com", "brand": { "legal_name": "Acme Plumbing LLC", "vertical": "CONSTRUCTION", "contact_email": "help@acmeplumbing.com", "contact_phone": "+14155550132" } }, "suggested": ["business_name", "use_case", "description", "opt_in_url", "privacy_url", "terms_url", "support_email", "brand.legal_name", "brand.vertical", "brand.contact_email", "brand.contact_phone"], "sources": { "business_name": "page", "opt_in_url": "page" }, "candidates": { "opt_in": [{ "url": "https://acmeplumbing.com/text-updates", "confidence": 1, "reason": "Has a phone number field and SMS consent wording" }], "privacy": [{ "url": "https://acmeplumbing.com/privacy", "confidence": 1, "reason": "Link labelled \"Privacy Policy\"" }], "terms": [{ "url": "https://acmeplumbing.com/terms", "confidence": 1, "reason": "Link labelled \"Terms of Service\"" }] }, "about": "Licensed plumbers serving Springfield since 1998.", "ai": false, "policy_checks": { "privacy": true, "terms": true } } ``` - `fields` is shaped like the body of `POST /v1/registrations`. Review it, add what only you know, and send it on. **We never suggest your EIN or entity type.** - `suggested` names every field we filled in, and `sources` says where each came from: `page` (read from your pages by fixed rules) or `ai`. - `candidates` lists the pages that could be your opt-in, privacy policy and terms, best first, each with the reason. `fields` holds the best one when we are confident enough; pick another from the list if we chose wrong. - `policy_checks` says whether the suggested privacy policy and terms pass the [website check](#what-the-website-check-looks-for)'s rules for them (the no-sharing clause; SMS terms with STOP and HELP), by the same fixed rules. - A site we cannot read returns `reachable: false` with the reason in `error`, and no suggestions. **What we read.** We fetch your homepage and the few pages it links to that look like your sign-up, contact, privacy policy and terms pages: public pages only, under the same rules as the website check below. Names, contact details, address and page links are read with fixed rules. Where drafting is available (`ai: true`), we also use the text of those public pages to draft the description, pick the use case and industry, and write three example messages (`fields.sample_messages`). Drafting uses nothing from your account ([Privacy §4](/privacy)), and can only choose a business name the page states and an opt-in page from `candidates`. If its examples would not pass the [example check](#your-example-messages) they are left out. The console asks for facts only: its "Add a number" form never suggests a use case or example messages, and nothing is drafted there. The toll-free verification form reads your site with the fixed rules only, with no drafting, and takes only your legal name, brand, address and policy pages. **Suggestions are drafts.** Nothing is saved or filed by this call. Example messages we draft are starter drafts: send them as `starter_messages` when you create the registration and it keeps `samples_source: "starter"` until you edit them (send `sample_messages`) or confirm them (`samples_confirmed: true`). Suggestions can be wrong; you are responsible for what you submit. One read per website per account every 10 minutes: asking again sooner returns the same suggestions. Limit: 10 websites a minute, separate from the website check's limit. ## Use cases | `use_case` | Registry use case | For | | --- | --- | --- | | `2fa` | 2FA | One-time passcodes | | `account_notification` | ACCOUNT_NOTIFICATION | Account alerts and notices | | `customer_care` | CUSTOMER_CARE | Support conversations | | `delivery_notification` | DELIVERY_NOTIFICATION | Order and delivery status | | `fraud_alert` | FRAUD_ALERT | Suspicious-activity alerts | | `security_alert` | SECURITY_ALERT | Account security notices | | `higher_education` | HIGHER_EDUCATION | Campus and enrollment updates | | `marketing` | MARKETING | Offers and promotions | | `mixed` | MIXED | Service messages and marketing | | `polling_voting` | POLLING_VOTING | Surveys (not political) | | `public_service_announcement` | PUBLIC_SERVICE_ANNOUNCEMENT | Community alerts | | `low_volume` | LOW_VOLUME | Small mixed programs | Pick the one that matches what you actually send. Sending a different kind of message than you registered is a policy violation ([Messaging Policy §6](/messaging-policy)); register another campaign instead. ## What the website check looks for | Finding | Passes when | | --- | --- | | `site_reachable` | Your website loads publicly over http(s). | | `business_identity` | The business name appears in the page text (legal suffixes like LLC are ignored). | | `privacy_policy_exists` | A privacy policy loads (we follow the "Privacy" link if you do not give a URL). | | `privacy_no_sharing` | It says mobile / SMS opt-in data is not shared with third parties. | | `terms_exist` | Terms of service load (we follow the "Terms" link). | | `terms_sms_program` | The terms describe the SMS program, with STOP and HELP. | | `optin_reachable` | The opt-in page loads. | | `optin_phone_field` | It has a phone number field, or a "Text KEYWORD to NUMBER" instruction. | | `optin_brand` | The disclosure names your brand. | | `optin_message_types` | It says what messages to expect. | | `optin_frequency` | "Message frequency varies", or a cadence like "4 msgs per week". | | `optin_rates` | "Message and data rates may apply" ("Msg & data" also passes). | | `optin_stop` | "Reply STOP to cancel". | | `optin_help` | "Reply HELP for help". | | `optin_links` | Links to the privacy policy and terms. | | `optin_not_prechecked` | The consent checkbox starts unchecked. | The check is fixed pattern matching. The generated copy is written to pass it: paste the fix, recheck, and that finding passes. Without an opt-in URL we check your homepage, and a missing phone field there is a warning rather than a failure. We fetch only the public pages you name (and the policy links on your homepage), from our servers, with a short timeout and a size cap. Addresses that are not on the public internet are never fetched. We keep the findings and their short evidence snippets, not the pages. ## Your example messages Carriers compare the examples on your registration with the texts you actually send. Examples that do not match your traffic are one of the most common reasons a registration is rejected, and a sender suspended later. So the examples we file are **yours**, not ours. Send them as `sample_messages`: 2 to 5 strings, 20 to 320 characters each, written as a recipient would get them (a real-looking name, order number or code, not a template variable). `description` (optional, 40 to 500 characters) says what you send and to whom; leave it out and we file a generated one. If you create a registration without `sample_messages`, it comes back with **starter drafts** for your use case and `samples_source: "starter"`: our templates, or the drafts written for your business if you sent `starter_messages` from [Fill in from your website](#fill-in-from-your-website) (drafts that would not pass the checks below are ignored). Starter drafts are never filed: edit them, or send `samples_confirmed: true` to `/recheck` if they already read like your texts. Either makes `samples_source` `"customer"`. `sample_check` holds the findings, in the same shape as the website check: | Finding | Passes when | | --- | --- | | `samples_confirmed` | The examples are yours (edited or confirmed), not starter drafts. | | `samples_count` | There are 2 to 5. | | `samples_length` | Each is 20 to 320 characters. | | `samples_brand` | Every example names your business (the name without LLC / Inc, its leading word, or its initials). | | `samples_opt_out` | At least one says how to stop ("Reply STOP to opt out"). Required for `marketing` and `mixed`; a warning for other use cases; not asked of `2fa`. | | `samples_links` | No public link shorteners. | | `samples_placeholders` | No unfilled variables like `{{name}}` or `[Brand]`. | | `samples_distinct` | No two examples are the same. | | `samples_use_case` | `2fa` only: an example shows a code. | | `samples_description` | Your own description, if you wrote one, is 40 to 500 characters. | | `samples_consent` | Nothing says the program texts people who never signed up (listings, scraped or bought numbers). | | `samples_content` | No example has content carriers refuse even with consent (high-risk lending, cannabis, gambling, phishing and the rest of the [Messaging Policy](/messaging-policy) prohibited list), the same rules every live send is screened with. | These are fixed rules. They cannot tell whether an example matches what you will send: that part is on you, and it is what carriers judge. A registration is `ready` only when the website check passes, the example check passes, and `samples_source` is `"customer"`. A recheck that changes only the examples does not fetch your website again. ## Business verification Carriers only accept registrations from real, verified businesses, so we verify each business once, before its first registration is filed. Three things make that possible: - **An authorized representative.** The contact on the registration (`brand.contact_name`, `contact_title`, `contact_email`, `contact_phone`) is a person who works for the business and can act for it. Their email must be at the business's own domain: a free mailbox (gmail.com, outlook.com and the like) or a shared inbox (info@, sales@, support@) is refused when you save the details, with a message naming it. Carriers verify the business by writing to that person. - **A street address.** The registered address is a street address in a US state or territory, with a ZIP code. A P.O. box is refused. - **A signed attestation.** The first submit for a business carries a statement signed by the representative, or by someone else who can act for the business: that they are authorized, that the details match the business's IRS records, and that the business will text only people who agreed to it and will follow the Messaging Policy. In the console you type your name and title and tick the box; through the API you send it with the submit: ```bash curl -X POST https://api.jointextkit.com/v1/registrations/reg_.../submit \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"attestation": {"signer_name": "Dana Smith", "signer_title": "Owner", "accepted": true}}' ``` Without a signature on file the submit answers `400 attestation_required` (`param: "attestation"`). We keep each signature as signed: the exact wording, the name and title, who sent it (the console user or the API key), the IP address, the browser, and the date of our legal documents at the time. It is never edited; a later signature is a new record. After the signed submit the registration is `submitted` and our team checks the business and the representative, usually within 1 business day. Part of that check compares the business's names and the representative's name with the US Treasury's sanctions list (OFAC): carriers do not register businesses tied to sanctioned people or countries. A name that happens to match is checked by a person, not refused automatically. `guidance` says so (`next_actor: "delivered"`). Nothing is filed with the carriers until the business is verified: - **Verified**: the registration is filed from there, exactly as you submitted it (another `registration.updated` event with `change: "business verified; filed"`). Later registrations for the same business file straight away with no new signature. - **Not verified**: the registration comes back `rejected` with `rejection.code: "business_unverified"`, the fix, and any note from our team. Correct the details, recheck, and submit again; that submit is signed again. A signature covers the business's legal name, entity type, EIN, address and representative. Changing any of those is a new business to us: the next submit needs a new signature and a new review. Changing the website, industry, use case or messages does not. Live access for the account needs a verified business, so the first verification is also what takes a sandbox account live when our team approves it (see [Going live](#going-live)). **Toll-free numbers.** A [toll-free verification](/docs/numbers#toll-free-numbers) form is signed the same way when you submit it, but it does not wait for our review: our team checks the form and sends it to the carriers, and when the carriers verify it, the business on the form is verified with us too and a sandbox account goes live. The exceptions: a business we have already declined, or one whose sanctions-list check needs a person to look, stays with our team. ## What we file, and the copy we keep When you submit, we store an exact copy of what was filed: your business details, example messages, description, the opt-in story, and how the checks stood. A resubmission adds a new copy; a copy is never changed. Read them at `GET /v1/registrations/{id}/submissions` or under **Filed versions** on the [console Registration page](/console/registration), so you can always show what the carriers reviewed. The copy is written when you submit, so while a registration waits on [business verification](#business-verification) its `filed_by` names our compliance team, who hold it until the business is verified and then file it unchanged. ## Opt-in, opt-out and help wording Three optional fields put your own words in what is filed: | Field | What it is | Rules | | --- | --- | --- | | `opt_in_flow` | How people opt in, in your words. | 40 to 500 characters. The opt-in page, the disclosure and the START keyword are added after it. | | `stop_reply` | The confirmation sent when someone opts out. | Names your business, says they are unsubscribed, no link. | | `help_reply` | The reply to HELP. | Names your business, gives a way to reach you, keeps the STOP instruction. | Leave them out and we file standard wording in your business name. A reply that drops a required part is a 400 that names it. Numbers on the registration send exactly the replies it filed. `includes_links`, `includes_phone_numbers`, `age_gated` and `direct_lending` are plain yes/no answers about your messages. A link or phone number in an example counts as yes whatever you send. ## Generated copy Every registration includes `generated`, built from your business name and use case: - `opt_in_cta`: the disclosure to place next to the phone field - `privacy_clause` and `sms_terms`: paste into your privacy policy and terms - `sample_messages`: the starter drafts (what is filed is the top-level `sample_messages`, repeated in `campaign.sample_messages`) - `help_reply`, `stop_reply`, `opt_in_confirmation` - `brand` and `campaign`: the full registration draft, including `message_flow`, keywords, and the embedded-link, embedded-phone, age-gated and direct-lending flags. `brand.missing` lists what the registry still needs from you (legal name, EIN, address, ...); send it in `brand`. ## Hosted opt-in page No sign-up page of your own, or one that keeps failing the check? Turn on a **hosted opt-in page** for the registration (console, the registration page, "Hosted opt-in page"). We host a **sign-up page** for your business at `jointextkit.com/optin/`: a phone field and an unchecked consent box, with every disclosure carriers look for (your business name, what you send and how often, "Msg & data rates may apply", STOP and HELP) and links to **your own** terms and privacy policy. We don't write those for you: your privacy policy needs the line that mobile numbers and consent are never shared for marketing. "Use as my opt-in page" files the sign-up page as the registration's opt-in page. The page is not indexed by search engines. Sign-ups are **double opt-in**. A sign-up is kept as a request (`opt_in_request` in the number's [consent history](/docs/opt-out)) with its evidence: the exact wording the person agreed to, the page, their IP address and browser, and the time. Once a number on the registration is approved and your account is live, that number texts them to reply YES. Their YES (or START) within 7 days makes it an [opt-in on file](/docs/compliance-checks#opt-in-on-file), with that evidence and the reply's message id, fires `message.opted_in` with `method: "hosted_page"`, and sends your confirmation text (`generated.opt_in_confirmation`). Before then, nothing counts: a sign-up while your account cannot text yet stays a request. - Typing someone else's number signs nobody up, and the page answers every sign-up the same way, so it never shows whether a number was texted or opted out. - A number that opted out is **not** signed back up and gets no text: the request is kept as evidence, and only a START from that phone lifts it. - US and Canadian numbers only; at most 10 sign-ups an hour from one network and 3 a day per number; at most 50 "reply YES" texts per page an hour (a sign-up past that is kept as a request, untexted). - Turning the page off stops sign-ups; its address and every record stay. **Toll-free numbers.** Verifying a toll-free number does not need a registration. When you have no hosted page and no opt-in page of your own, the toll-free verification form comes with one: the same three pages, written from the form (the name on your texts, what you send, your website and contact) and kept in step with it until you submit. The form's opt-in page, terms and privacy addresses and its description of how people opt in are filled in from it. A sign-up there is an opt-in on file for the account (a toll-free number has no registration to scope it to), and once one of your toll-free numbers is verified it also gets a confirmation text from that number. It is a convenience, not legal advice: you remain responsible for using the consent the way you described it. ## API ```bash # Create (runs the website check immediately) curl -X POST https://api.jointextkit.com/v1/registrations \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "business_name": "Acme Plumbing LLC", "website": "https://acmeplumbing.com", "opt_in_url": "https://acmeplumbing.com/text-updates", "use_case": "customer_care", "support_email": "help@acmeplumbing.com", "sample_messages": [ "Acme Plumbing: Hi Sam, your plumber Dana arrives tomorrow between 9 and 11 AM. Reply STOP to opt out.", "Acme Plumbing: Your job #4821 is done. Reply here with any questions about the repair." ], "brand": { "legal_name": "Acme Plumbing LLC", "ein": "12-3456789" } }' # Fix the site, then recheck (any field can be corrected in the same call) curl -X POST https://api.jointextkit.com/v1/registrations/reg_.../recheck \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"opt_in_url": "https://acmeplumbing.com/signup"}' # Submit once status is "ready". Every submit acknowledges the fee terms (see # Registration fees); the first submit for a business also carries the signed # attestation (see Business verification), later ones need none. curl -X POST https://api.jointextkit.com/v1/registrations/reg_.../submit \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"fees_acknowledged": true, "attestation": {"signer_name": "Dana Smith", "signer_title": "Owner", "accepted": true}}' # Get one / its filed copies / list all curl https://api.jointextkit.com/v1/registrations/reg_... \ -H "Authorization: Bearer $TEXTKIT_API_KEY" curl https://api.jointextkit.com/v1/registrations/reg_.../submissions \ -H "Authorization: Bearer $TEXTKIT_API_KEY" curl https://api.jointextkit.com/v1/registrations \ -H "Authorization: Bearer $TEXTKIT_API_KEY" ``` A response, trimmed: ```json { "id": "reg_a1B2c3D4e5F6", "object": "registration", "status": "checks_failed", "guidance": { "what_happened": "The website check found 1 problem: consent box is not pre-checked.", "why": "Carrier reviewers reject registrations whose website or opt-in is missing these disclosures. Fixing them now avoids a rejection later.", "next_actor": "you", "next_step": "Fix each failed item using its copy-paste text, publish the change on your website, then click Recheck website. If a URL was wrong, correct it when you recheck.", "after": "When every check passes the registration becomes Ready to submit." }, "check": { "passed": 15, "failed": 1, "findings": [{ "id": "optin_not_prechecked", "status": "fail", "evidence": { "url": "https://acmeplumbing.com/text-updates", "snippet": "" }, "why": "Consent must be an action the person takes...", "fix": { "summary": "Remove the `checked` attribute from the consent checkbox...", "copy": null } }] }, "rejection": null, "generated": { "opt_in_cta": "By checking this box, I agree to receive ..." } } ``` States: `draft` → `checks_failed` / `ready` → `submitted` → `in_review` → `approved` / `rejected`. Recheck works in `draft`, `checks_failed`, `ready` and `rejected`; submit only in `ready`. Anything else returns **409** `invalid_state` with a message saying what to do instead. Creating and rechecking share a budget of 20 website checks per minute per account. ## Rejections A rejection carries `rejection.code`, a plain `explanation`, the exact `fix`, any `note` from our compliance team, and `related_findings`; those findings are marked `flagged_by_rejection` on the next recheck. Codes: `website_unreachable`, `brand_mismatch`, `privacy_sharing_clause`, `terms_missing_sms`, `optin_disclosure_incomplete`, `optin_not_verifiable`, `prechecked_consent`, `samples_missing_brand`, `samples_use_case_mismatch`, `message_flow_unclear`, `url_shortener_or_link_mismatch`, `restricted_content`, `business_unverified` (our team could not verify the business before filing; see [Business verification](#business-verification)), `other`. ## Events `registration.updated` fires on every status change (pollable at `/v1/events`, pushed to webhooks). `data` has `id`, `status`, `previous_status`, `change` (e.g. `resubmitted`), and `reason_code` on rejections. ## Sending before approval A live number is **test only until its registration is approved**: it can text your verified numbers (your own phone, and any number you verify under Billing → Verified numbers), so you can build and test with real texts while the carriers review. It cannot text anyone else yet. Carriers block traffic from unregistered numbers, so we stop it before it leaves rather than let it fail downstream. A send to an unverified recipient from a number that is not yet active answers: ``` HTTP/1.1 403 X-TextKit-Sender-State: test_only | pending | action_needed { "error": { "code": "sender_not_registered", "param": "from", "message": "+14155550132 is still being registered with the carriers, so it cannot text this recipient yet. ..." } } ``` Nothing is sent and nothing is charged. Once the registration is approved, every live number on the account is linked to it automatically and becomes `active`; watch `sender.state` on [the number](/docs/numbers#sender-status) or subscribe to `number.sender_updated`. Test keys and sandbox numbers are never affected. A toll-free number follows the same rule until the carriers verify it. An account that bought one before it had live access can send live with a live key, but until the account is live, only from that toll-free number and only to your verified numbers; a send from any other number answers the same `403 sender_not_registered`, saying your account is not live yet (see [Toll-free numbers](/docs/numbers#toll-free-numbers)). ## Registration fees Carrier registration fees are **separate from the per-segment rate**, billed at twice what the registry or carrier charges us ([Terms §4.3](/terms)). There are three: a one-time **brand** fee when your business is first registered, a **vetting** fee each time a campaign is filed (a resubmission is filed again), and a **monthly campaign fee** for as long as the campaign is registered, billed for a **minimum of 3 months** from approval. They are **non-refundable, including when a registration is rejected**: the registry charges us whether or not it approves. No page prints an amount, because the amount is whatever the carrier bills, and it can change. Submitting asks you to acknowledge exactly that. In the console it is a box on the review step; over the API the submit body must carry `fees_acknowledged: true` (otherwise **400** `invalid_request`, param `fees_acknowledged`, and nothing is filed). The acknowledgment is kept on the filed copy (`fee_acknowledgment`: the wording, the time, your IP). Each fee is recorded on the registration as the registry charges us, starting as an estimate that our team confirms against the carrier's bill, and is listed under **Registration fees** on the [Registration page](/console/registration) and on [Billing](/console/billing). Fees are billed on **their own invoice**, never from your prepaid credits: with a saved card on the account the invoice is charged to it; without one it is emailed, with **14 days** to pay. The account's owner and admins get an email either way, and a receipt when it is paid. A fee invoice that cannot be charged never pauses sending; you get the link to pay it, and our team is told. If our team records an approval by mistake (nothing was filed with the carriers, so they charged nothing), we undo it and waive the fees that approval started. You are never billed a campaign fee for an approval the carriers did not make; an approval that later happens for real starts them again. ## Going live Submitting a registration from a sandbox account is also your request for live access. There is no second form: we read the business, website, use case and opt-in from the registration. You can keep building in the sandbox while both are reviewed. A registration must be complete before it can be submitted. If business details are missing (legal name, tax ID, address, industry, the representative's name, title, work email and phone), submit answers **400** naming them, and the registration stays `ready`. The first submit for a business also needs the signed [attestation](#business-verification), and our team verifies the business before filing anything; live access is approved only for an account with a verified business. Carrier review usually takes 3 to 7 business days. If it runs past 10 days we flag the registration (`review_stalled: true`), our team follows up with the carriers, and your numbers show `action_needed` with an explanation, so a review never sits silently. ## Opt-in proof [Messaging Policy §1.4](/messaging-policy) asks you to keep evidence of every consent. Record it with the opt-in and it lives in your consent ledger, next to the opt-in it proves: ```bash curl -X POST https://api.jointextkit.com/v1/consent/+14155550132 \ -H "Authorization: Bearer $TEXTKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "opted_in", "proof": { "source": "web_form", "collected_at": "2026-09-30T18:04:05Z", "page_url": "https://acmeplumbing.com/text-updates", "disclosure": "By checking this box, I agree to receive ...", "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 ...", "registration_id": "reg_a1B2c3D4e5F6" } }' ``` | Field | | | --- | --- | | `source` | Required. `web_form`, `keyword`, `paper`, `checkout`, or `verbal`. | | `collected_at` | When they consented (ISO or epoch ms). Defaults to now. | | `page_url` | Required for `web_form`. | | `disclosure` | The exact wording they saw (up to 2,000 characters). | | `ip`, `user_agent` | For online forms. | | `registration_id`, `campaign_id` | The registration or carrier campaign it was collected under. | `GET /v1/consent/{phone}` returns `proof` on the history entry, and `GET /v1/consent/export?type=ledger` exports every consent event with `proof_*` columns. Keep proof for at least four years after your last message to the number. This page is educational, not legal advice. --- # Compliance checks Every message you send, through the API, the console, a broadcast, a scheduled send, a recurring schedule or an automation, goes through the same checks in the same order before it reaches a carrier. You call `POST /v1/messages`; the checks are on by default. A send that fails a check is **blocked**: it is not sent, it is not charged, the API says why, and the attempt is in your log with the reason. ``` +14155550123 Blocked Quiet hours +12125550148 Blocked Previously opted out +13125550190 Delivered ``` ## The checks, in order The first check that fails is the one you hear about. | # | Check | What it looks at | Returns | `failure.code` | Runs in | | --- | --- | --- | --- | --- | --- | | 1 | **Account open** | The account is open and not suspended. A closed account sends nothing (403 account_closed); a suspended one sends nothing until our team restores it (403 tenant_suspended, recorded as account_suspended). | `403 account_closed` | `account_closed` | Live and sandbox | | 2 | **Opt-out list** | The recipient has opted out of your messages (replied STOP, or was imported or recorded as opted out). One list for the whole account, every number and customer. | `403 recipient_opted_out` | `opted_out` | Live and sandbox. Send to +15005550011, or opt a number out with the consent API. | | 3 | **Sending not paused** | All live sending is paused after three messages blocked as phishing, impersonation or fraud reached two or more people within 7 days, or by our team, until our team lifts it. Keys keep working; a batch or scheduled message waits; your test recipients are exempt. | `403 account_paused` | `account_paused` | Live | | 4 | **Destination** | The number is in a US or Canadian area code. Other +1 regions are refused. | `403 forbidden` | `destination_not_allowed` | Live | | 5 | **Sender registration** | A US local number may text the public only once it is linked to an approved registration. Until then it reaches only your verified numbers. | `403 sender_not_registered` | `sender_not_registered` | Live | | 6 | **Per-recipient rate** | No more than 30 messages to one number in an hour. | `429 rate_limited` | `rate_limited` | Live. Send to +15005550012. | | 7 | **Burst to one person** | No more than 10 messages from your account to one person in 5 minutes, or 25 while they are texting you back. Your test recipients are exempt. | `429 rate_limited` | `rate_limited` | Live | | 8 | **One person, few numbers** | A person your account texted from two of its numbers in the last 24 hours is not texted from a third: carriers treat it as filter evasion. A reply to someone who texted that number is never refused. | `403 sender_rotation` | `sender_rotation` | Live | | 9 | **Verified recipients (free accounts)** | A free account texts only numbers it has verified. | `403 recipient_not_verified` | `recipient_not_verified` | Live | | 10 | **Matches the registration's use case** | What kind of message this is comes from the sending number's registration and the message's wording, not from the category you send: a category can make a message stricter ("marketing") but never looser. A message outside the registration's use case is refused: promotional wording (an offer, a discount, a promo code, "shop now") on a non-marketing registration, category "marketing" on one, or anything but a one-time code from a two-factor registration. On a mixed or low-volume registration a message is marketing unless it is a one-time code or you sent it with category "transactional" and it has no promotional wording. From a number with no registration, a broadcast, category "marketing" or promotional wording makes it marketing. Then, for a number linked to a registration (not a two-factor one), an automated classifier compares each new kind of message (names, numbers and links masked) with the registration's use case, description and example messages: a kind that clearly doesn't match is refused, promotional content on a mixed or low-volume registration counts as marketing, and a kind it can't place is held for our team (see Review below). It only ever makes a message stricter. The first message of a kind waits for it; later ones reuse the answer. If it can't run, a new kind is not sent (503 screen_unavailable; batches and scheduled messages retry for about 15 minutes). A registration that says its messages contain no phone numbers refuses one with a phone number written in it, such as (415) 555-0132 (not your own test recipients). Links must be on the website domain of the registration (or of a toll-free number's verification), a domain in its filed example messages or links or one our team added, and must not redirect elsewhere (see Links); a registration or verification that declared no links refuses them. Before the content checks, we add your business name to a person's first message and opt-out wording to marketing when they are missing (see What we add). | `403 campaign_mismatch` | `campaign_mismatch` | Live | | 11 | **Quiet hours** | Any message outside 8:00am to 9:00pm in the recipient's local time (read from the area code; 8:00pm for Florida, Maryland and Oklahoma numbers, by state law) is held until 8:00am; a single marketing send is refused unless you ask for it to be held. Only one-time codes, replies within 4 hours of the recipient's own text, and your test numbers go out at night. | `403 quiet_hours` | `quiet_hours` | Live and sandbox. Send to +15005550015: marketing is refused, anything else is held. | | 12 | **Opt-in on file** | A marketing message goes only to a number with an opt-in on file: one recorded through the consent API, a START reply, or a contact import that marks it opted in. From a number linked to a registration the opt-in must be for that registration (campaign_id), and that holds for every recurring message from it, receipts and alerts included, not only marketing; a non-marketing message can carry the opt-in itself ("consent": {"source", "collected_at"}), and a reply within 4 hours of the recipient's own message needs none. From a number with no registration, an account-level opt-in counts for marketing and other messages are not checked. One-time codes are never checked. Your verified numbers are exempt. | `403 consent_required` | `consent_required` | Live and sandbox. Send to +15005550016 with category "marketing". The other 500-555 numbers count as opted in. | | 13 | **Re-confirmation after 30 days** | A marketing message from a number linked to a registration, to a number that has not been messaged from it in 30 days: we text the recipient first to confirm they still want messages, hold this message, and send it by itself if they reply YES within 7 days (reconfirmation_pending). No reply, and the held message is dropped and marked reconfirmation_expired; marketing to that number is refused until they opt in again. Your verified numbers are exempt. Transactional messages and one-time codes are not held. An admin can turn this off, with an acknowledgment that is audit-logged. | `403 reconfirmation_pending` | `reconfirmation_pending` | Live and sandbox. Record an opt-in for a registration (campaign_id) dated more than 30 days ago, then send marketing from a number linked to it. | | 14 | **Registration paused** | Marketing from a number whose registration is paused because more than 4% of its recipients opted out in 24 hours (with at least 100 sent), or any message from a number whose registration our team paused for review. Either stays paused until our team resumes it. An opt-out-rate pause does not stop transactional messages; your verified numbers are exempt from both. | `403 campaign_paused` | `campaign_paused` | Live | | 15 | **Content screen** | Every live message is checked against the Messaging Policy: our content rules, then a moderation model and a scam classifier, which is told the business name and website on the sending number's registration or toll-free verification so your own alerts pass and imitations of other companies do not. Phishing, impersonation and scams, prohibited content (including everything carriers refuse even with consent, such as cannabis, gambling, payday loans, crypto and stock alerts) and public link shorteners are refused; alcohol is refused until approved for your account. A message the screen could not check is not sent: 503 screen_unavailable with Retry-After (batches and scheduled messages wait and retry for about 15 minutes first). | `403 forbidden` | `content_blocked` | Live | | 16 | **Review of new kinds of message** | During an account's first 30 days of live sending, each new kind of message from a number linked to a registration is reviewed by our team before it goes out (messages that differ only in names, numbers, amounts or links are one kind). The first ones are held (held_for_review, nothing charged); once we approve the kind, every held message is sent and later ones go straight out. A kind we reject is refused (review_rejected), and held messages not reviewed within 3 days are dropped (review_expired). A kind the registration check above can't place is held the same way at any age of the account. Your verified numbers and one-time codes from a two-factor registration are exempt. | `403 held_for_review` | `held_for_review` | Live | | 17 | **Daily cap per business** | Carriers cap how many messages a business may send a day across all its registered numbers, by its tax ID (EIN): 2,000 a day unless its carrier vetting score allows more. A batch or scheduled message over the cap waits for the next day (UTC); a direct send is refused with a Retry-After. | `429 rate_limited` | `rate_limited` | Live | | 18 | **New-account daily people** | In an account's first 30 days live, the different people it texts a day are capped at the larger of 200 and 3x the daily volume it declared. People already texted that day are never refused; a batch or scheduled message waits for the next day (UTC). | `429 rate_limited` | `rate_limited` | Live | | 19 | **State limit on marketing texts** | Florida, Maryland and Oklahoma allow 3 marketing texts to one person in 24 hours (read from the area code). A 4th from your account is refused, whatever it is about; a batch or scheduled message waits until it is allowed. Replies within 4 hours of the person's own text and your test numbers don't count. | `429 rate_limited` | `rate_limited` | Live | | 20 | **Registration sending pace** | Carriers limit how fast one registration may send: 240 messages a minute across its numbers (75 for low-volume). A batch or scheduled message over the pace waits for the next minute; a direct send is refused with a Retry-After. | `429 rate_limited` | `rate_limited` | Live | | 21 | **Message allowance** | Your plan's daily and monthly message allowance. | `429 quota_exceeded` | `quota_exceeded` | Live and sandbox | | 22 | **Spend limit** | The monthly spending limit you set, if you set one. | `403 spend_limit_reached` | `spend_limit_reached` | Live | | 23 | **Prepaid credits** | The balance covers this message's price. | `402 insufficient_credits` | `insufficient_credits` | Live | After the last check the message goes to the carrier. What a carrier does with it afterwards (filtering, an unreachable phone) is a [delivery failure](/docs/errors#delivery-failures), not a block. This table is generated from the list the send pipeline is tested against, so it cannot fall out of step with the code. ## What a blocked send looks like The request answers with the status and code in the table, and a `failure` object you can show to a person: ```json { "error": { "code": "quiet_hours", "message": "It is 10:41pm for this recipient (America/New_York, from the number's area code). Messages are sent between 8:00am and 9:00pm in the recipient's local time, so this one was not sent and nothing was charged. ...", "param": "to", "recipient_local_time": "22:41", "timezone": "America/New_York", "send_after": "2026-10-06T12:00:00.000Z", "category": "marketing", "category_basis": "request", "message_id": "msg_8Fq2ZkT0aLw4nXcV", "failure": { "code": "quiet_hours", "title": "Held back for quiet hours", "explanation": "...", "action": "Recommended: ...", "carrier_code": null }, "request_id": "req_8Fq2ZkT0aLw4nXcV7pHs" } } ``` The attempt is stored as a message with `status: "blocked"` and the same `failure`. Its `price` is 0, it counts toward no allowance or limit, and it is not part of the conversation with that number. - **Console.** Logs shows the status **Blocked** and the reason. Filter by Blocked, then by reason. Compliance shows blocked sends for the last 7 and 30 days, by reason. - **API.** `GET /v1/messages?status=blocked` lists them; `&blocked_reason=quiet_hours` narrows to one reason. `failure_code` matches failed and blocked messages alike. - **Webhook.** `message.blocked`, with `reason`, `message_id` and `failure`. - **Export.** The messages CSV has `reason_code` and `reason` columns. Two refusals a looping client can repeat without end (the per-recipient rate and the message allowance) stop adding rows after 500 an hour per account. They are still refused and still counted. ### Reason labels The label is what Logs and the CSV show for each code. | `failure.code` | Label | Status | | --- | --- | --- | | `carrier_filtered` | Filtered as spam | `failed` | | `unreachable` | Phone unreachable | `failed` | | `invalid_number` | Number not in service | `failed` | | `opted_out` | Previously opted out | `blocked` | | `campaign_mismatch` | Outside registration use case | `blocked` | | `content_blocked` | Content policy | `blocked` | | `screen_unavailable` | Content check unavailable | `blocked` | | `rate_limited` | Sending too fast | `blocked` | | `landline` | Number can't receive texts | `failed` | | `carrier_rejected` | Rejected by carrier | `failed` | | `spend_limit_reached` | Spend limit | `blocked` | | `insufficient_credits` | Out of credits | `blocked` | | `sender_not_registered` | Sender not registered | `blocked` | | `quiet_hours` | Quiet hours | `blocked` | | `consent_required` | No opt-in on file | `blocked` | | `reconfirmation_pending` | Awaiting re-confirmation | `blocked` | | `reconfirmation_expired` | Did not re-confirm | `blocked` | | `held_for_review` | Waiting for review | `blocked` | | `review_rejected` | Not approved in review | `blocked` | | `review_expired` | Review timed out | `blocked` | | `campaign_paused` | Registration paused | `blocked` | | `sender_rotation` | Too many of your numbers | `blocked` | | `recipient_not_verified` | Not a verified test recipient | `blocked` | | `quota_exceeded` | Quota | `blocked` | | `destination_not_allowed` | Destination not served | `blocked` | | `account_closed` | Account closed | `blocked` | | `account_suspended` | Account suspended | `blocked` | | `account_paused` | Sending paused for review | `blocked` | | `topic_unsubscribed` | Unsubscribed from topic | `blocked` | | `unknown` | Not delivered | `failed` | ## Quiet hours Every message is sent between **8:00am and 9:00pm in the recipient's local time**, and **8:00am to 8:00pm** to numbers with a Florida, Maryland or Oklahoma area code. That is the federal calling-time window for telephone solicitations and those states' narrower one, and we apply it to all traffic, not only marketing. A message outside it is held until 8:00am, or refused if it is a single marketing send. This cannot be turned off. Sent at any hour: one-time codes (judged by the message's wording, not its `category`), a reply to someone who texted that number in the last 4 hours, the "Reply YES to confirm" text a hosted opt-in page sends right after someone signs up, and sends to your own verified test numbers. ### Which messages are marketing What a message is comes from the **sending number's registration** and the **message's wording**, not from a label. Carriers approve a registration for one use case and treat anything outside it as a campaign violation, so we check it before they do ([matches the registration's use case](#the-checks-in-order)). A `"category"` on the request can make a message stricter (`"marketing"`), never looser: | Sending number's registration | The message is | | --- | --- | | Two-factor authentication | A one-time code. Anything else is refused (`campaign_mismatch`) | | Account, customer care, delivery, fraud and security alerts, higher education, public service, polling | Not marketing. Promotional wording (an offer, a discount, a promo code, "shop now") or `"category": "marketing"` is refused (`campaign_mismatch`) | | Marketing | Marketing, always | | Mixed, Low-volume | Marketing, unless it is a one-time code, or you sent `"category": "transactional"` and it has no promotional wording | | None (the sandbox, a number not yet linked) | Marketing when it is a broadcast or batch, has promotional wording, or you sent `"category": "marketing"`; otherwise not | **The registration check.** For a number linked to a registration (any use case but two-factor), and for a toll-free number verified through the console's form, the first message of each new kind is also compared with the registration or toll-free verification as you filed it (its use case, description and example messages) by an automated classifier. "Kind" means the text with names, numbers, amounts, emails and links masked, so "Your order 1234 shipped" and "Your order 5678 shipped" are one kind. What it can do: - **Refuse** a kind that clearly doesn't match the registration, or a promotional one on a registration that isn't for marketing: `403 campaign_mismatch` with `mismatch: "off_use_case"` (or `"promotional"`), `checked_by: "consistency_check"` and the `fingerprint`. The message says why. - **Count it as marketing** when it is promotional on a mixed or low-volume registration, so every marketing check applies. - **Hold it for our team** when it can't tell (`403 held_for_review`, nothing charged), at any age of the account. It never makes a message looser than the table above. Its answer is kept per kind, so later messages of the same kind don't wait for it; editing the registration checks each kind again. If it can't run, a new kind is not sent (`503 screen_unavailable`). Think it got one wrong? Contact support: our team can approve that kind, and it then goes straight out. Whether a message is marketing decides one thing for quiet hours: a single marketing send is refused by default, anything else is held. ### Local time We read the recipient's time zone from the area code of their number, for every US and Canadian area code. An area code tells us where a number was issued, not where the phone is: someone who moved and kept their number is read in their old time zone. It is the best signal available without location data, and it is an approximation. - An area code that spans two time zones is treated as quiet when it is quiet in **either** one. - A number whose area code shows no time zone (toll-free, for example) is held to every time zone in the contiguous US: it is texted between 11:00am and 9:00pm Eastern. - Daylight time, and the places that do not observe it (Arizona, Hawaii, Saskatchewan), follow the standard time zone database. ### Refuse or hold ```bash curl https://api.jointextkit.com/v1/messages \ -H "Authorization: Bearer $TEXTKIT_API_KEY" -H "Content-Type: application/json" \ -d '{"to": "+12125550148", "body": "20% off this week. Reply STOP to opt out.", "category": "marketing", "quiet_hours": "defer"}' ``` | `quiet_hours` | In quiet hours | | --- | --- | | `"block"` | Refused with `403 quiet_hours`. The body has `recipient_local_time`, `timezone` and `send_after`, the next allowed time. | | `"defer"` | Held. The response is `202` with a [scheduled message](/docs/scheduled) set for the recipient's next 8:00am, plus a `deferred` object. It is listed and can be canceled like any scheduled message, and runs every check again when it is sent. | Without `quiet_hours`, a message that is not marketing is held. For a marketing message the default depends on what is sending: | Sent by | Default | | --- | --- | | `POST /v1/messages`, the console composer | Refuse | | `POST /v1/messages` with `scheduled_at` | Refuse when you schedule it, if the scheduled time is in quiet hours | | Broadcasts and batches | Hold, recipient by recipient | | Recurring schedules | Hold | | Automations | Hold | A broadcast sent at 10:00pm Eastern reaches recipients on the West Coast right away and recipients on the East Coast at 8:00am their time. A held message that is somehow still in quiet hours when it comes due is held again, at most 3 times, and then blocked. An automatic carrier retry that would land in quiet hours fails with `quiet_hours` instead of going out, and is not charged. ### Account default Console → Compliance → Quiet hours sets the default for your account: - **Refuse single marketing sends, hold the rest** (the default). - **Hold everything until 8:00am**, single marketing sends included. There is no off setting. A change is recorded in your [audit log](/docs/audit-logs) as `quiet_hours.updated`. A `quiet_hours` value on a request always wins over the account default. ### Test it Quiet hours work the same with a test key. Two ways to trigger them: - Send to `+15005550015`. For this number it is always night: a marketing message is refused, anything else is held. Only a message that reads as a one-time code goes out. - Send to any real-looking number: it is read against its area code, as in live mode. Nothing is delivered. ### What quiet hours do not do - They apply only Florida's, Maryland's and Oklahoma's 8:00pm end. Other states' narrower hours, weekend hours and holiday restrictions are not applied. If you send into such a state, schedule accordingly. - They do not know where a phone is, only where its number was issued. - They do not decide whether a message may be sent at all. Consent, and the hours that apply where your recipients are, remain yours. ## Links From a number linked to a registration, or a toll-free number verified through our form, a message may link only to: - the **website domain** on your registration or verification, and its subdomains; - a domain in the **example messages** (or, for toll-free, the links in messages) you filed; - a domain **our team added** for the registration at your request (a booking or payment page you use, for example). Anything else is refused with `403 campaign_mismatch` (`mismatch`: `link_domain`), and so is a link that **redirects** off those domains: we visit each link once for each kind of message and follow up to five redirects, without reading the page. We ask with `HEAD` (`GET` only if your site refuses `HEAD`), so a one-time link is not used up by the check. If the registration says its messages contain no links, any link is refused (`mismatch`: `link`). A domain made to look like another (`paypa1.com`, one letter off your own, an internationalized look-alike) is refused as phishing (`403 forbidden`, `failure.code` `content_blocked`). Your own test recipients are exempt. Public link shorteners are always refused. ## What we add Carriers expect two things in messages from a registered number, and we add them when they are missing instead of refusing the message (from a number linked to a registration; never to a one-time code, or to a reply within 4 hours of a message from the person): - **Your business name on a person's first message** from that number. If the text does not name your business (any case or punctuation, with or without "LLC" or "Inc."), we put "Your Business: " in front, once per person and number. - **Opt-out wording on marketing**: on the first marketing message to a person, and again if they have not had opt-out wording from that number in 30 days. If the text has none ("Reply STOP…", "Text STOP to end" and the like count), we add " Reply STOP to opt out." at the end. The message's `body` is the text as sent, and `disclosures_added` (`brand`, `opt_out`) says what we added, on the API response, the `message.sent` event and in Logs. The added words can make a message one segment longer, and each segment is billed (rate plus carrier fee). Content checks run on the text as sent. A registration also says whether its messages contain **phone numbers**. If it says they don't, a message with one written as a phone number (such as (415) 555-0132 or +1 415 555 0132; a bare run of ten digits, like an order number, does not count) is refused with `403 campaign_mismatch`: remove the number, or contact support to update the registration. ## Opt-in on file A message goes only to a number with an **opt-in on file** when it is: - **marketing** ([which messages are marketing](#which-messages-are-marketing)), from any number; or - a **recurring non-marketing message** (a receipt, an alert, a reminder) from a number linked to a registration. Carriers expect consent for those too, and ask for proof of it when they audit a campaign. Without one it is refused with `403 consent_required`, stored in your log as a `blocked` message with that reason, and not charged. Never checked: one-time codes, a non-marketing reply within 4 hours of a message from the recipient, and non-marketing messages from a number with no registration. An opt-in is on file when you recorded it, and the number has not opted out since, in any of these ways: - `POST /v1/consent/{phone}` with `"status": "opted_in"` (add `proof`: where and how they agreed, the disclosure they saw; and `campaign_id`, the registration they opted in to); - **Record opt-in**, a console button on the contact's page (or on Add contact, "They agreed to get texts"), or right from Send message when a message is refused for having none. It asks how they agreed, when, the page for a web form and the wording they saw, and records it for your registration with the same rules as the API (an opt-in given out loud is not offered for a marketing registration: those need written consent); - a [contact import](/docs/contacts) row with an `opt_in` column (and a `campaign_id` column for the registration); - the person texting START (or another opt-in keyword) to your number, which records an opt-in for that number's registration; - a sign-up on the registration's [hosted opt-in page](/docs/compliance#hosted-opt-in-page); - for a non-marketing message only, `consent` on the send itself (`{ "source": "checkout", "collected_at": "..." }`), which records the opt-in for the sending number's registration when none is on file. **Which opt-in counts depends on the sending number.** From a number linked to a registration, the opt-in must be for that registration: consent is given to one business for one kind of message, and a reminders opt-in is not a promotions opt-in. From a number with no registration (the sandbox, toll-free and Canadian numbers, a number not yet linked), any opt-in on file with the account counts. The refusal names the registration it looked for (`registration_id`) and `consent_scope`. An opt-out is never narrower than the account. See [consent is per registration](/docs/opt-out#consent-is-per-registration) and [proof for marketing opt-ins](/docs/opt-out#proof-for-marketing-opt-ins). Your verified numbers and your own phone are exempt, so you can always try a marketing message on yourself. A broadcast skips each recipient without an opt-in and sends to the rest; the skipped ones appear in its results and in your log. We check that an opt-in is on file, not that the person really agreed or that the consent covers what you send: that stays yours to hold and produce on request ([Messaging Policy](/messaging-policy)). ## Re-confirmation after 30 days Some carriers require that a person who has not heard from a marketing program in 30 days confirms they still want it before the next promotional text. The check runs right after the opt-in check, for a marketing message from a number linked to a registration, when the recipient has not been messaged from that number in **30 days** (the opt-in's own date counts as contact): 1. The message is **held**, and one re-confirmation text goes out first in your business's name, with STOP and HELP: *"Acme: It has been a while. Reply YES to keep getting texts from Acme. Msg & data rates may apply. Reply STOP to cancel, HELP for help."* 2. The request answers `403 reconfirmation_pending` with `expires_at` and `held: true`; the held message is in your log as `blocked` with that reason, and `message.blocked` carries it. Nothing is charged. 3. If they reply **YES** (or Y, OK, SURE, CONFIRM, START, JOIN) within **7 days**, the held message is queued and sent by itself through the whole pipeline, and `message.opted_in` fires with `reconfirmed: true` and `released_message_ids`. Marketing to them resumes. 4. Other marketing messages to the same number while the question is open answer `403 reconfirmation_pending` (with `held: false`): the person is asked once, not once per message. 5. No reply in 7 days: the held message is **dropped**, its row in Logs changes to `reconfirmation_expired` (announced again on `message.blocked` with the same `message_id`), and marketing to that number is refused with `403 reconfirmation_expired` until they opt in again: a YES or START reply, or a new opt-in you record with proof. Transactional messages, one-time codes, marketing from a number with no registration, and your verified test numbers are never held. A STOP ends the question like any other opt-out. ### Turning it off An admin can turn re-confirmation off on the console Compliance page, after acknowledging that the account handles it itself; the acknowledgment and the change are recorded in the [audit log](/docs/audit-logs) as `reconfirmation.updated`. It is never off by default. ### Test it In the sandbox, link a test number to a registration with use case `marketing`, record an opt-in for that registration with `"collected_at"` more than 30 days ago, and send a marketing message to the number: the request answers `reconfirmation_pending` and the sandbox records the re-confirmation text. Simulate a YES with `POST /v1/test/inbound` and the held message goes out on the next queue run. ## Review of new kinds of message During your account's **first 30 days of live sending**, our team looks at each new kind of message from a number linked to a registration, or a toll-free number verified through the console's form, before it goes out. It runs after the content screen, so we only ever look at text the screen passed. A **kind of message** is the text with the parts that change masked: names, numbers, amounts, dates, codes and email addresses, and links reduced to their domain. "Hi Sam, order 1234 shipped" and "Hi Alex, order 5678 shipped" are one kind; a broadcast or batch is one kind for all its recipients, whatever its `{{merge}}` fields hold. 1. The first messages of a new kind are **held**: the request answers `403 held_for_review` with `held: true`, `fingerprint` (the kind's id), `registration_id` and `expires_at`. Each is in your log as `blocked` with that reason and announced on `message.blocked`. Nothing is charged. 2. We aim to review within **one business day**. **Approved**: every held message of that kind is sent by itself through all the checks again (quiet hours and the opt-out list included), its row in Logs says it was sent as a new message, and later messages of that kind go straight out. `template.approved` is in your [audit log](/docs/audit-logs). 3. **Rejected**: the held messages are dropped, their rows change to `review_rejected` (announced again on `message.blocked` with the same `message_id`), and later messages of that kind from that registration are refused with `403 review_rejected`. The reason is in the message, and your audit log records it as `template.rejected`. This applies after your first 30 days too. 4. **Not reviewed within 3 days**: the held messages are dropped and their rows change to `review_expired`. Send again: if the kind has been approved since, it goes straight out. Up to 10,000 messages of one kind are held; past that a send answers `held_for_review` with `held: false` and is not kept, so send it again once the kind is approved. A broadcast's results count its held messages as not sent; once approved they go out as individual messages. Exempt: your verified numbers and your own phone (so you can try anything on yourself first), one-time codes from a two-factor registration, and the confirmation text of a hosted opt-in page. Keyword replies, auto-replies and Verify codes are not held. A quick way through: send each kind of message to yourself, then once to a real recipient, early. Our team can keep reviewing an account after 30 days, or stop sooner; the change is in your audit log as `template_review.updated`. ## Sending pace Carriers limit how fast one registration may send. Every live message from a number linked to a registration counts against the registration's pace, whichever of its numbers sends it: | Registration | Pace | | --- | --- | | Low-volume | 75 messages a minute | | Every other use case | 240 messages a minute | - **Batches, broadcasts and scheduled messages** over the pace wait for the next minute and go out then. Nothing fails and nothing is charged twice; the batch simply takes longer. - **A direct send** (`POST /v1/messages`, the console) over the pace is refused with `429 rate_limited` and a `Retry-After`, recorded as `blocked`, not charged. Send it again, or use a batch. - Carriers also cap how many messages a business may send some of their subscribers in a day. A message refused for that comes back `failed` with `rate_limited`: send it again the next day. These are the carriers' defaults for a new registration; a registration with a higher limit is not paced faster yet. ## Daily caps Two daily limits count people and messages per day (UTC). Over either, a batch, broadcast or scheduled message waits for the next day and goes out then; a direct send is refused with `429 rate_limited` and a `Retry-After` until midnight UTC, recorded as `blocked`, not charged. Your verified test recipients are never counted or refused. | Cap | Limit | | --- | --- | | **Per business** | Messages from numbers linked to registrations, across every account of a business, by its tax ID (EIN): the carriers' cap of **2,000 a day** for a brand without a vetting score. If your brand has a vetting score that allows more, contact us and we raise it. | | **New accounts** | In an account's first 30 days live, the **different people** it texts a day: the larger of **200** and **3x the daily volume** declared when it asked for live access. People already texted that day are never refused. Contact us to raise it. | ## Per-person limits - **Bursts.** No more than **10 messages from your account to one person in 5 minutes**, or **25** while they are texting you back (they wrote within the last hour). Over it: `429 rate_limited` with a `Retry-After`. - **One person, few numbers.** A person your account texted from **two** of its numbers in the last 24 hours is not texted from a third: `403 sender_rotation`, naming the two numbers to use. Carriers treat one person reached from many numbers as dodging their filters. A reply to someone who texted the number is never refused, and a [sender pool](/docs/numbers#sender-pools) (sticky by default) keeps each person on one number. - **State limit on marketing.** Florida, Maryland and Oklahoma allow **3 marketing texts to one person in 24 hours**, read from the area code. A 4th from your account, whatever it is about, gets `429 rate_limited` with a `Retry-After`; a batch, broadcast or scheduled message waits until it is allowed. Replies within 4 hours of the person's own text don't count. ## The enforcement ladder Every registration is judged on its last 7 days: complaints (replies like "spam", "who is this", "never signed up", "reported" or "wrong number", and verified reports from people we texted, see below), the share of its messages carriers filtered, its opt-out rate, strikes, and kinds of message our team rejected. Rates count once it has sent 200 messages in the 7 days. | Step | What happens | Reached at | | --- | --- | --- | | **Warning** | An email, a `compliance.flagged` webhook event, a banner in the console | Complaints over 0.1%, filtered over 5%, opt-outs over 0.5%, one strike, or a rejected kind of message | | **Half speed** | The registration sends at half its [pace](#sending-pace) and half its business's [daily cap](#daily-caps); batches simply take longer | Complaints over 0.3%, filtered over 15%, opt-outs over 2%, two strikes or two rejected kinds of message in 7 days | | **Paused** | Its numbers send nothing (`403 campaign_paused`); `compliance.paused` fires | Complaints over 0.5%, opt-outs over 4%, or one of its numbers filtered on 95% of at least 20 messages over 3 days | Each step is recorded in your [audit log](/docs/audit-logs) with its reasons. A step's webhook event (`compliance.flagged` for a warning or half speed, `compliance.paused` when paused, `compliance.resumed` when our team lifts it) carries `scope` (`"registration"` with `registration_id`, or `"account"`), the `level`, and the `reasons`. The automatic pause of all sending sends `compliance.paused` with `scope: "account"`. The console shows a banner with the reasons and an **Appeal** form (owners and admins). A registration moves back down only when our team moves it, with a reason. **Strikes and the automatic pause.** A message blocked as phishing, impersonation or fraud, a kind of message our team rejected, texting people from a third number on three or more people in a day, or abuse our team confirms is a strike. Three phishing, impersonation or fraud strikes to two or more people within 7 days pause all of your account's live sending (`403 account_paused`) until our team reviews it. Your keys and data keep working and batches wait. **Complaints and reports.** We read replies for complaint phrases and keep only that a complaint happened, never the reply. A "wrong number" reply also opts that person out. Anyone can report a text at [/report](/report); when it matches a text your number sent them in the last 30 days, it counts as a complaint and they are opted out. Their number is never shown to you. ## Opt-out rate Carriers watch how many people opt out of a brand's messages. We count, for each registration over the last 24 hours, the messages carriers accepted from its numbers and the people who opted out by texting them (STOP, or a plain "stop texting me"). | Opt-out rate (24h) | What happens | | --- | --- | | Above **0.5%**, with at least 200 sent | A warning: email to your account's owner and admins, a `compliance.flagged` [webhook event](/docs/webhooks), and a review by our team. Nothing is paused. At most once a day per registration | | Above **4%**, with at least 100 sent | **Marketing from the registration's numbers is paused**: refused with `403 campaign_paused`, stored as `blocked`, not charged. Transactional messages and one-time codes keep going. `compliance.paused` fires, and the registration shows `marketing_paused` | A paused registration stays paused until our team has looked at it with you and resumes it (`compliance.resumed`); the 24-hour count then starts again. Opt-outs you record yourself (the consent API, an import) are not counted. Your verified numbers are exempt, so you can still test. A high opt-out rate almost always means some people did not expect the messages: check the opt-in, that every message names your business, and how often you send. ### Test it With a test key, `+15005550016` never has an opt-in: send it a marketing message to see the refusal. The other 500-555 numbers count as opted in. A real-looking number is checked exactly as in live mode, so record its opt-in first. ## What is not checked These are not part of the pipeline. Nothing on this page should be read as covering them. - **Reassigned numbers.** We do not check whether a number has changed hands since the person gave you consent. No reassigned-number database is queried. - **Litigator and complainer lists.** We do not screen recipients against any list of known litigants or serial complainants. - **Do-not-call registries.** We do not check national or state do-not-call lists. - **Consent itself.** For marketing messages we check that an opt-in is on file, and we enforce opt-outs, but we cannot verify that a recipient really agreed to hear from you, or that the consent covers what you send. That stays your responsibility. - **Other state sending rules.** Quiet hours use the federal 8:00am to 9:00pm window, and 8:00pm for Florida, Maryland and Oklahoma numbers. Other states' stricter hours, weekend hours and holiday rules are not applied. - **The recipient's actual location.** Local time is read from the number's area code, which says where the number was issued, not where the phone is. You remain responsible for having consent for every recipient, for classifying your traffic correctly, and for following the laws that apply to your messages. These checks enforce specific rules by default; they are not legal advice and do not make a messaging program compliant on their own. See the [Messaging Policy](/messaging-policy). --- # Security best practices TextKit secures the platform; you secure your side of it. This page covers what we do, what you should do, and how to recognize the attacks that target messaging accounts. ## Protect your API keys - Admins can view and copy a key from the console; members and viewers can't. Every view is in the audit log, and the key itself is never logged. Keys are looked up by keyed hash and kept encrypted at rest. - Keep keys in environment variables or a secrets manager, never in source control, client-side code, or chat logs. A key in a public repo should be treated as compromised and rotated immediately. - Rotate keys periodically (we recommend at least every 90 days) and on any team departure. Rotation is instant in the console under **API keys**. - Use test keys everywhere except production. Test keys cannot send real traffic, so a leaked test key cannot run up a bill or spam anyone. - Give each service a [restricted key](/docs/authentication) with only the scopes it needs, and set a [spend limit](/docs/spend-limits) so a leaked live key has a ceiling. - Review the [audit log](/docs/audit-logs) for key and team changes you don't recognize. ## Protect your account - Console passwords must be at least 12 characters with uppercase, lowercase, numeric, and special characters. Use a password manager and a unique password. - Accounts lock temporarily after repeated failed sign-in attempts, and console sessions on a device expire after 30 days of inactivity (every sign-in still needs its one-time code). - Every password sign-in after the one that creates your account needs a one-time code (by text to your verified phone, or email), and you can add an authenticator app under Settings. After 30 wrong authenticator codes in a day (or 50 wrong texted or emailed codes), that kind of code is refused until the next day; the other kind still works, and you are emailed. - **Sign out everywhere** (Settings) ends every sign-in to your account at once, this one included. Resetting your password does the same. - Only invite teammates who need access, and remove them when they leave. ### Security emails We email you when something changes that could mean someone else is in your account. They go out whatever your email preferences, and none of them carries message content or a full key: | When | Who gets it | | --- | --- | | A new API key is created (in the console, by rolling a key, or by approving a command-line sign-in) | The account owner and every admin | | Someone signs in from a browser we have not seen for them (with the browser, approximate location and IP address) | The person who signed in | | An authenticator app is added or removed, or the phone that receives sign-in codes changes | That person | | A password is reset | That person | | Many wrong sign-in codes are entered in a day (someone may have the password) | That person, once a day | | Someone joins the team | The account owner and every admin | If one of these was not you or someone on your team: reset your password, roll your API keys, and reply to the email so we can lock the account while you sort it out. ### Unusual API key activity We watch every **live** key against its own history (test keys can't reach a real phone, so they aren't watched): - **A new country.** The first time a key makes a request from a country it hasn't used before, the account owner and admins get an email (the very first country a key is used from is where it lives, not news). - **A sudden spike.** When a key's requests today pass 500 and ten times its daily average over the previous week, the owner and admins are emailed and our team is told. Nothing is paused. - **Both together.** A spike from a country the key started using in the last day is how a stolen key looks, so we **pause the key**: calls with it get `403 key_paused`, everything else on the account keeps working, and the owner and admins are emailed. An admin can resume it under **API keys** if it was them (it won't be paused again that day), or roll it. - **Blocked messages.** When our content checks block far more of the account's messages than usual (at least 20 in a day and five times its daily average), the owner and admins are emailed and our team takes a look. We check every 10 minutes. These alerts carry counts, the key's name and first characters, the country and IP address; never message content. ## Recognize social engineering Messaging accounts are a target: attackers want your sending capability, your keys, or your customers' trust. - **We will never ask for your password or a full API key** - not by email, not by text, not in support conversations. Anyone who does is not us. - Verify unusual requests **out of band**: if an email or message asks you to change payment details, share credentials, or approve access, confirm through a channel you already trust (the console, or a contact you already have) before acting. - Check sender addresses carefully. Our email comes from the joinsimplesms.com domain and links point to jointextkit.com; lookalike domains are the most common phishing tell. - Spear phishing is personalized - a message knowing your name, role, or vendor list is not proof it is genuine. - Report anything suspicious to [support@joinsimplesms.com](mailto:support@joinsimplesms.com); we investigate every report. ## Protect your users - Send only to recipients who opted in, honor opt-outs (we enforce STOP and its variants platform-wide), and identify yourself as the sender - see [Opt-out & consent](/docs/opt-out). - Never put credentials, one-time codes you did not generate, or sensitive personal data in outbound messages beyond what the use case requires. - Verify [webhook signatures](/docs/webhooks) so spoofed callbacks cannot inject fake delivery or inbound events into your systems. ## What we do on our side - TLS for all data in transit; encryption at rest; API keys stored as keyed hashes and verification codes as salted hashes, never logged in plaintext. - Real-time content filtering, velocity limits, and 24x7 automated fraud monitoring with automatic suspension of abusive traffic. - Production access restricted to authorized personnel and audited. - Details in the [Privacy Policy](/privacy). Found a vulnerability? Email [support@joinsimplesms.com](mailto:support@joinsimplesms.com) with details; we respond quickly and appreciate responsible disclosure. --- # Deliverability Every final outcome of an outbound message (delivered or failed) is counted by day, by the recipient's carrier and country, by the number you sent from, by message category, by platform customer, by [tag](/docs/messages#send-a-message), and (for failures) by failure code. Read the numbers from the API, or open [Console → Deliverability](/console/deliverability) for trends, your worst carriers and numbers, and a breakdown of failure reasons. Test keys report sandbox traffic; live keys report live traffic. The console's Analytics page shows live traffic only. Counts only: no message content is part of this data. ## GET /v1/deliverability ```bash curl "https://api.jointextkit.com/v1/deliverability?group_by=carrier&start_date=2026-09-01&end_date=2026-09-30" \ -H "Authorization: Bearer ssms_sk_live_..." ``` | Parameter | Default | Notes | | --- | --- | --- | | `group_by` | `day` | `day`, `carrier`, `number`, `country`, `category`, `customer`, `error_code`, or `tag` | | `start_date` | 6 days before `end_date` | `YYYY-MM-DD`, UTC, inclusive | | `end_date` | today | `YYYY-MM-DD`, UTC, inclusive. Ranges are limited to 90 days. | ```json { "object": "deliverability", "mode": "live", "start_date": "2026-09-01", "end_date": "2026-09-30", "group_by": "carrier", "totals": { "key": "total", "delivered": 9412, "failed": 88, "total": 9500, "delivery_rate": 0.9907, "failure_reasons": { "invalid_number": 51, "carrier_filtered": 37 } }, "data": [ { "key": "example_wireless", "delivered": 2100, "failed": 61, "total": 2161, "delivery_rate": 0.9718, "failure_reasons": { "carrier_filtered": 37, "invalid_number": 24 } } ] } ``` `failure_reasons` counts failed messages by their [failure code](/docs/errors#delivery-failures). `day` rows come back in date order; `carrier` and `number` rows worst first. The carrier is known when the recipient has been looked up recently (`GET /v1/lookup`); otherwise it is `unknown`. Numbers are keyed by their 10 digits. `country` is `US` or `CA` (toll-free counts as `US`); `category` and `customer` are `none` for messages sent without one; `error_code` rows are failed messages by our [failure code](/docs/errors#delivery-failures), most frequent first. `tag` has a row per tag you sent with, plus `_untagged`; a message with two tags counts in both rows, so the rows can add up to more than the totals. These five are counted from 2026-10-24 on. Needs the `messages:read` scope on a restricted key. ## Degradation alerts Every 15 minutes we compare each account's last 2 hours with its previous 7 days, for all traffic, each carrier, and each sending number. When the delivery rate falls sharply (at least 15 points and at least 20% below normal, with at least 20 recent and 100 baseline outcomes, so a handful of failures can't trigger it), we: - show the alert at the top of the Deliverability page, - send a `deliverability.degraded` [webhook event](/docs/webhooks), - and alert our team. ```json { "type": "deliverability.degraded", "data": { "alert_id": "dal_...", "dimension": "number", "key": "4155550132", "window_rate": 0.41, "baseline_rate": 0.97, "window_total": 230, "baseline_total": 18400, "window_hours": 2 } } ``` `dimension` is `account` (all traffic), `carrier`, or `number`. Each series alerts at most once every 6 hours. --- # Credits TextKit is prepaid. You buy credits with a card, usage draws your balance down, and a call your balance cannot cover is refused instead of being sent on account. Add credits in [Console → Billing](/console/billing). ## Buying credits - Pick $10, $20, $50 or $100, or any amount from $5 to $1,000. - Your card is saved by the purchase so auto-recharge can use it. - Your first purchase moves the account off the free tier: every cap is lifted, you can text any number, and live carrier lookups are unlocked. It is also needed before you can buy a number. A balance of zero does not move you back; calls are refused with a `402` until you add credits. - Promotional credit (a grant from us) does not count as that first purchase: on its own it does not buy a number. After your first purchase it is drawn like any other credit. - Credits do not expire while your account is open. ## What draws credits | | Drawn | | --- | --- | | Outbound SMS | $0.0032 per segment, plus the carrier fee per segment, when the carrier accepts it. Drawn before the send and returned if the carrier rejects it or every automatic retry fails. | | Phone number | $0.95 when you buy a number and every month after, on the same day. Not prorated. | | Carrier lookup | $0.008 per lookup; returned if the lookup fails. | | Verification | $0.025 per successful verification only. | | Inbound SMS | Nothing. Inbound messages are never refused or delayed for lack of credits. | | Sandbox (test keys) | Nothing, ever. | | Free-tier allowance | Nothing. | Carrier fees are passed through at cost, per segment ([the table](/docs/pricing)). Registration fees (brand, vetting, the monthly campaign fee) are separate and are **never drawn from credits**: they go on their own invoice, charged to your saved card or emailed with 14 days to pay. They are listed under **Registration fees** on Billing. See [Compliance & registration](/docs/compliance#registration-fees). Each month you get a statement that itemises usage; your credits pay it. ## When the balance cannot cover a call The call answers `402` and nothing is sent or charged: ```json { "error": { "code": "insufficient_credits", "message": "Not enough credits: your balance is $0.00 and this message costs $0.0082, so it was not done and you were not charged. Add credits in the console (Billing), or turn on auto-recharge there.", "balance_micro_usd": 4000, "required_micro_usd": 8200, "top_up_url": "https://jointextkit.com/console/billing", "failure": { "code": "insufficient_credits", "title": "Out of credits", "explanation": "...", "action": "Recommended: ...", "carrier_code": null } } } ``` Amounts are micro-dollars (millionths of a dollar). The same code comes back from every billable call: sending a message, starting a verification, a lookup, and buying a number. With auto-recharge on, a call the balance cannot cover first adds your auto-recharge amount from the saved card and then goes ahead (buying a number included). Only if that charge fails is the call refused: the 402 then also carries `auto_recharge`: `declined` (the card was refused and auto-recharge is now off) or `unavailable` (the card could not be reached; it stays on). Queued work is not dropped silently and is not retried for you: - **Scheduled messages** that come due with too few credits fail with `insufficient_credits`. Schedule them again after topping up. - **Batches and broadcasts** keep running; each row the balance cannot cover fails with `insufficient_credits` and the results say so. Use "Retry failed" (or `POST /v1/batches/{id}/retry`) after topping up. - **Automations** fail the step with `insufficient_credits`. - **Number renewals**: if a number's monthly fee cannot be covered, the number is kept, never released. Outbound from the account is paused until you add credits, and the fee is taken then. ## Alerts Each fires once, and is re-armed when a top-up lifts the balance back over the mark. We email the account's owner and admins and send a [webhook event](/docs/webhooks): | Event | When | | --- | --- | | `credits.low` | The balance fell below your alert threshold ($5 unless you change it in Billing). | | `credits.depleted` | The first call refused for lack of credits (or a number fee that could not be covered: `reason` is `number_fee`). | | `credits.auto_recharge_failed` | Your card was declined; auto-recharge has been switched off. | | `credits.topped_up` | Credits were added (`source`: `topup`, `auto_recharge` or `grant`). | ```json { "type": "credits.low", "data": { "balance_usd": 4.99, "balance_micro_usd": 4991000, "threshold_usd": 5, "top_up_url": "https://jointextkit.com/console/billing" } } ``` ## Receipts Every payment is confirmed by email to the account's owner and admins, once, however many ways the payment reaches us: - **Credits bought** (a top-up or an auto-recharge): the amount, the card's brand and last four, the date, the balance after, and a link to the payment's receipt page. - **A monthly statement** your credits did not cover: the amount and the invoice. - **A refund**: the amount and the card it goes back to. Banks usually show it within 5 to 10 business days. The credits it bought come off the balance. - **A live number** bought (the first month, taken from credits, and when it renews) or released (its monthly fee stops). Promotional credits send no receipt. Billing email is sent whatever your email preferences: it is how you know what was charged. ## Auto-recharge Off by default. Turn it on in Billing: "when the balance falls below $X, add $Y", charged to your saved card. If the card is declined, auto-recharge switches itself off, you are told, and it stays off until you turn it back on. ## Disputed payments If a payment to us is disputed with the card issuer (a chargeback), auto-recharge switches off and anything that draws on your credits is refused with `insufficient_credits` and a message saying a payment is disputed, until the dispute is resolved (or we lift the pause sooner). If the dispute is decided in the cardholder's favor, the credits that payment bought that are still unused come off the balance. Number renewals continue. ## Balance API ```bash curl https://api.jointextkit.com/v1/balance -H "Authorization: Bearer ssms_sk_live_..." ``` ```json { "object": "balance", "balance_micro_usd": 12340000, "balance_usd": 12.34, "currency": "usd", "low_balance_usd": 5, "auto_recharge": { "enabled": false, "threshold_usd": 10, "amount_usd": 20 }, "top_up_url": "https://jointextkit.com/console/billing" } ``` A restricted key needs the `billing` scope. The credit history is available as CSV from `GET /v1/exports/credit_history`. Credits work alongside a [spend limit](/docs/spend-limits): the limit is checked first, then the balance. --- # Spend limits Set a monthly cap in USD and live messages stop at it instead of running up a bill. Alerts fire as you approach it. Set it in [Console → Settings](/console/settings) (admins) or with the API. ## How it works - Each live outbound message counts at the per-segment rate ($0.0032) plus its carrier fee against this month's estimated spend, **before** it is sent. If it would take you past the cap, it is not sent and you are not charged. - A message the carrier rejects is refunded against the cap, as it is on your balance. - The month resets on the 1st, UTC. - Sandbox (test keys) is free and never counted. Free-tier accounts are capped by their quota and cost nothing. - The cap covers outbound messages. Numbers, lookups, and verifications are charged as usual and are not counted toward it. - The cap is separate from your [credit balance](/docs/credits): a send has to fit under both. - The estimate is for your protection; your monthly statement is the source of truth. When the cap is reached, sends answer: ```json { "error": { "code": "spend_limit_reached", "message": "Monthly spend limit reached: $50.00 of your $50.00 limit is used this month, so this message was not sent and you were not charged. Sending resumes on the 1st (UTC). To keep sending now, raise or remove the limit in the console (Settings → Spend limit) or with PATCH /v1/spend-limit." } } ``` (HTTP 403). Raising or removing the limit takes effect on the next send. ## Alerts `alert_thresholds` are percentages of the cap (default 50, 80, and 100). Each one fires once per month: an email to the account owner and a `spend.threshold_reached` [webhook event](/docs/webhooks): ```json { "type": "spend.threshold_reached", "data": { "threshold_percent": 80, "spent_usd": 40.01, "limit_usd": 50, "month": "2026-10" } } ``` Changing the cap re-arms the alerts for the new amount. ## API ```bash curl https://api.jointextkit.com/v1/spend-limit -H "Authorization: Bearer ssms_sk_live_..." curl -X PATCH https://api.jointextkit.com/v1/spend-limit \ -H "Authorization: Bearer ssms_sk_live_..." -H "Content-Type: application/json" \ -d '{"monthly_limit_usd": 50, "alert_thresholds": [50, 80, 100]}' ``` ```json { "object": "spend_limit", "monthly_limit_usd": 50, "alert_thresholds": [50, 80, 100], "applies_to": ["outbound_sms"], "estimated_cost_per_message_usd": 0.0082, "current_month": { "month": "2026-10", "spent_usd": 12.6, "remaining_usd": 37.4 }, "updated_at": "2026-10-01T17:02:11.000Z" } ``` `monthly_limit_usd` is 1 to 1,000,000, or `null` for no cap. Up to 5 thresholds, whole percentages from 1 to 100. Needs the `billing` scope on a restricted key. Every change is recorded in the [audit log](/docs/audit-logs). --- # Usage `GET /v1/usage` returns what [Console → Usage](/console/usage) shows: your account's counters for the last few months, today so far, and the limits your plan enforces. Use it to chart consumption in your own dashboard or to alert before a daily limit is reached. ```bash curl "https://api.jointextkit.com/v1/usage?months=3" \ -H "Authorization: Bearer $TEXTKIT_API_KEY" ``` ```json { "object": "usage", "months": [ { "month": "2026-10", "messages": 412, "messages_test": 37, "lookups": 18, "verifications": 9, "api_requests": 1280 }, { "month": "2026-09", "messages": 1904, "messages_test": 112, "lookups": 61, "verifications": 40, "api_requests": 5120 }, { "month": "2026-08", "messages": 0, "messages_test": 64, "lookups": 0, "verifications": 0, "api_requests": 211 } ], "today": { "date": "2026-10-05", "messages": 23, "messages_test": 4, "lookups": 2, "verifications": 1, "api_requests": 96 }, "limits": { "messages_per_day": 10000, "messages_per_month": null, "lookups_per_day": 5000, "verifications_per_day": 2000, "verifications_per_month": null } } ``` `months` is 1 to 6 (default 3). The current month comes first and is month-to-date; a month with no traffic is listed with zeros. Months and days are UTC, the same boundaries the limits reset on. ## Counters | Field | Counts | | --- | --- | | `messages` | Live outbound messages accepted for sending. A message the carrier rejects is taken back off the count. | | `messages_test` | [Sandbox](/docs/sandbox) messages, sent with a test key. Free, and never counted toward a limit. | | `lookups` | [Lookups](/docs/lookup), live and sandbox together. | | `verifications` | Live [verifications](/docs/verify) that were approved. Sandbox verifications are not counted. | | `api_requests` | Authenticated requests to `/v1`, including this one. | The numbers are for the whole account, so a test key and a live key read the same response. For usage split by the businesses you send for, see [Customers](/docs/customers). ## Limits `limits` are the caps applied to live traffic on your plan. `null` means no cap. Compare `today.messages` with `limits.messages_per_day`, and the first entry of `months` with `limits.messages_per_month`. A `0` means that product is not available on your plan yet: live lookups, for example, need a paid account. These are counts, not money. For dollars, read your [credit balance](/docs/credits) or this month's estimated spend against your [spend limit](/docs/spend-limits). Daily counters are also available as CSV from `GET /v1/exports/usage`. A restricted key needs the `billing` scope. --- # Audit log Every administrative change to your account is recorded with who made it, what they changed, when, and from which IP address. Admins see it in [Console → Audit log](/console/audit-log); the API serves the same entries. ## What is recorded | Action | When | | --- | --- | | `api_key.created`, `api_key.rolled`, `api_key.revoked` | Key changes in the console (a key created by `textkit login` carries `via: cli_login`) | | `api_key.viewed` | An admin viewed or copied a key in the console (the key itself is never logged) | | `api_key.paused`, `api_key.resumed` | Our key monitor paused a live key (a sudden spike from a new country: requests today and the countries), or an admin resumed it | | `cli_login.approved`, `cli_login.denied` | Someone approved or denied a [CLI login](/docs/cli#login) request (with the machine name the CLI sent) | | `team.invite_created`, `team.invite_accepted`, `team.invite_revoked` | Invitations | | `team.role_changed`, `team.member_removed`, `team.member_left` | Membership changes | | `webhook.created`, `webhook.updated`, `webhook.deleted` | Webhook endpoints (console or API) | | `webhook.secret_rotated` | A webhook endpoint's signing secret was replaced (the secret itself is never logged) | | `webhook.secret_viewed` | Someone revealed a webhook endpoint's signing secret in the console (the secret itself is never logged) | | `spend_limit.updated` | [Spend limit](/docs/spend-limits) changes | | `credits.topped_up` | [Credits](/docs/credits) were bought (by a person or by auto-recharge) | | `credits.settings_updated` | Auto-recharge or the low-balance alert changed, including auto-recharge switching itself off after a declined card | | `number.purchased`, `number.released`, `number.assigned` | Numbers (bought, released, or assigned to a customer) | | `consent.updated`, `consent.imported` | Manual opt-out / opt-in overrides and imports | | `migration.opt_outs_attested` | An admin confirmed there are no opt-outs to carry over from a previous provider | | `quiet_hours.updated` | The account's [quiet-hours](/docs/compliance-checks#quiet-hours) default changed | | `reconfirmation.updated` | The account's [re-confirmation](/docs/compliance-checks#re-confirmation-after-30-days) setting changed; turning it off records the acknowledgment | | `contact.topic_updated` | A contact's subscription to a [topic](/docs/contacts#subscription-topics) changed | | `link_domain.created`, `link_domain.deleted`, `link_domain.activated`, `link_domain.deactivated` | [Link domains](/docs/messages#link-shortening) added or deleted, and turned on or off (on by itself once its DNS checks out) | | `contacts.imported` | A contact import finished (row counts, never the rows) | | `segment.created`, `segment.updated`, `segment.deleted` | Audience segments | | `topic.created`, `topic.updated`, `topic.deleted` | Subscription topics | | `settings.updated` | Account name, auto-replies, verified recipients, live-access requests | | `number.registration_set`, `number.sender_linked`, `number.sender_unlinked` | A number was attached to a [registration](/docs/numbers#sender-status), linked with the carriers, or unlinked (on release, when moved, or when our team undid an approval) | | `number.attached` | Our team added a number we ordered for you (a toll-free number, say) to your account, with or without the monthly number fee | | `number.toll_free_updated` | Our team recorded a [toll-free verification](/docs/numbers#toll-free-numbers) result for a number: status, business and use case | | `registration.approved`, `live_access.approved` | Our team approved a registration, or live access for the account | | `live_access.denied` | Our team denied a live-access request, with the reason. The account stays in the sandbox and can ask again | | `registration.approval_undone` | Our team undid an approval it recorded by mistake: the registration went back to the filing queue or was returned to you with a reason, its numbers stopped texting the public, and the registration fees that approval started were waived. Live access is unchanged | | `registration_fees.invoiced` | Our team billed carrier registration fees: the invoice id, the amount and whether the saved card was charged | | `business.attested`, `business.verified`, `business.rejected` | Someone signed the [business attestation](/docs/compliance#business-verification) (who, and for which registration), or our team verified or could not verify the business (with the reason code). Never the full EIN | | `opt_in_page.enabled`, `opt_in_page.disabled` | A hosted [opt-in page](/docs/compliance#hosted-opt-in-page) for a registration was turned on or off | | `registration.marketing_paused`, `registration.marketing_resumed` | Marketing on a registration was paused for a high [opt-out rate](/docs/compliance-checks#opt-out-rate), or our team resumed it (with the reason) | | `registration.sending_paused`, `registration.sending_resumed` | Our team paused every message from a registration's numbers for review, or resumed it (with the reason) | | `registration.content_approved`, `registration.content_approval_revoked` | Our team approved a [restricted content](/messaging-policy) category for a registration, or revoked it (`category`, with the reason) | | `registration.link_domain_added`, `registration.link_domain_removed` | Our team allowed a domain in a registration's [links](/docs/compliance-checks#links), or removed it (`domain`, with the reason) | | `template.approved`, `template.rejected` | Our team [reviewed a new kind of message](/docs/compliance-checks#review-of-new-kinds-of-message) for a registration (`fingerprint`, the reason, how many held messages were sent or dropped) | | `template_review.updated` | Our team changed whether the account's new kinds of message are reviewed (`setting`: `auto` for the first 30 days of live sending, `always` or `off`, with the reason) | | `account.suspended`, `account.unsuspended` | Our team suspended the account, or lifted the suspension (with the reason) | | `account.sending_paused`, `account.sending_resumed` | All live sending was paused (automatically, after messages blocked as phishing, impersonation or fraud, or by our team), or the pause was lifted (with the reason) | | `account.strikes_reset` | Our team cleared the account's strikes (with the reason) | | `account.security_code_sent` | TextKit texted a sign-in or phone-verification code for the account (the phone's last four digits) | | `registration.ladder_raised`, `account.ladder_raised` | The [enforcement ladder](/docs/compliance-checks#the-enforcement-ladder) moved a registration (or the account) to a warning, half pace, or paused, with the reasons | | `registration.ladder_lowered`, `account.ladder_lowered` | Our team moved it back down (with the reason) | | `account.appealed` | Someone on your team appealed a restriction from the console | | `automation.created`, `automation.updated`, `automation.activated`, `automation.paused`, `automation.deleted` | [Automations](/docs/automations) (name, trigger event and step count; never message text) | | `automation.identity_unlinked` | A `user_id` to phone link was removed with `DELETE /v1/automations/identities/{user_id}` (the `user_id`; never the number) | | `privacy.phone_erased` | TextKit staff deleted one phone number's data from your account on a privacy request (the number's last four digits, what was removed as counts, and the reason). The number's opt-out is kept | | `message.deleted`, `message.redacted` | A message was [deleted or redacted](/docs/messages#delete-or-redact-a-message) through the API (its direction and status, the other number's last four digits, and how many events and request logs were changed; never the text) | | `privacy.email_erased` | TextKit staff deleted one email address's data (its [Verify](/docs/verify) email codes and the events about them) from your account on a privacy request (the address's domain only, what was removed as counts, and the reason) | | `schedule.created`, `schedule.updated`, `schedule.paused`, `schedule.resumed`, `schedule.deleted` | [Recurring schedules](/docs/schedules) (frequency, time, time zone and which fields changed; never the message text or the recipient) | | `account.closed`, `account.reopened` | The account was [closed](/docs/teams#closing-your-account), by its owner or by our team (with what closing stopped and when the data is deleted), or our team reopened it before then. The log is readable in the console until the account's data is deleted, and is deleted with it | Entries never contain message content, secrets, or full invite links. ## GET /v1/audit-logs ```bash curl "https://api.jointextkit.com/v1/audit-logs?limit=25" -H "Authorization: Bearer ssms_sk_live_..." ``` ```json { "data": [ { "id": "aud_a1B2c3D4e5F6g7H8", "object": "audit_log", "action": "api_key.created", "actor": { "type": "user", "id": "uid_...", "email": "dev@example.com", "name": "Dev" }, "target": { "type": "api_key", "id": "key_x9Y8z7W6v5U4" }, "metadata": { "mode": "live", "name": "Billing worker", "scopes": ["messages:send"] }, "ip": "203.0.113.7", "created_at": "2026-10-01T17:02:11.000Z" } ], "has_more": true, "next_cursor": "..." } ``` Newest first. Pass `next_cursor` back as `cursor` for the next page; `limit` is 1 to 100 (default 25). `actor.type` is `user` (console) or `api_key` (with the key's id and name). Needs the `audit_logs:read` scope on a restricted key. ### Filters | Parameter | Matches | | --- | --- | | `action` | One action (`number.released`), or a whole group with a trailing dot (`team.`, `api_key.`) | | `actor` | The actor's id (exact), or any part of their email, case-insensitive | | `target_type` | The target's type: `api_key`, `member`, `invite`, `webhook`, `number`, `phone`, ... | | `created_after` | Entries at or after this time (ISO 8601) | | `created_before` | Entries at or before this time (ISO 8601); a bare date means the end of that day, UTC | ```bash curl "https://api.jointextkit.com/v1/audit-logs?action=team.&created_after=2026-09-01&created_before=2026-09-30" \ -H "Authorization: Bearer ssms_sk_live_..." ``` Filters apply before paging, so a page holds `limit` matching entries. One request scans at most 5,000 entries: on a long log with a rare filter, a page can come back short (or empty) with `has_more: true`. Keep following `next_cursor` until `has_more` is `false`. A date range narrows the scan itself, so it is the fastest filter. An invalid filter returns `400 invalid_request`. ## Export as CSV `GET /v1/exports/audit_log` streams the same entries as CSV and takes the same filters. Columns: `id`, `created_at`, `action`, `actor_type`, `actor_id`, `actor_email`, `actor_name`, `target_type`, `target_id`, `ip`, `metadata` (JSON). Up to 50,000 rows per export; same `audit_logs:read` scope. In the console, **Export CSV** on the Audit log page downloads the current filter (admins only). ```bash curl "https://api.jointextkit.com/v1/exports/audit_log?created_after=2026-01-01" \ -H "Authorization: Bearer ssms_sk_live_..." -o audit-log.csv ``` --- # Errors Every error is JSON with a stable envelope: ```json { "error": { "code": "invalid_request", "message": "`to` must be a valid US/Canada number in E.164 format.", "param": "to", "request_id": "req_8Fq2ZkT0aLw4nXcV7pHs" } } ``` ## Request IDs Every response from an endpoint that takes your API key, success or error, carries an `X-Request-Id` header (`req_…`), and error bodies repeat it as `request_id`. Look it up in the console under **Logs** to see the request and response as we received and sent them (API keys, secrets, verification codes, and card data are redacted; bodies are truncated at 8 KB; logs are kept for 30 days). Include it when you contact support. ## Codes | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | A parameter is missing or malformed; `param` names it. | | 400 | `link_domain_required` | `shorten_links` was set but the account has no active link domain. Nothing was sent. See [link shortening](/docs/messages#link-shortening). | | 401 | `invalid_api_key` | Missing, malformed, revoked, or unknown key. | | 403 | `key_paused` | This live key is paused: it suddenly made far more requests than usual from a new country. An admin resumes or rolls it under **API keys**. See [unusual key activity](/docs/security#unusual-api-key-activity). | | 403 | `number_purchase_held` | New live numbers are on hold: a number carriers were filtering was released and replaced within 7 days. Existing numbers keep working; contact support. See [replacing a filtered number](/docs/numbers#replacing-a-filtered-number). | | 403 | `live_access_required` | Needs live access (or a live key before approval), or a live key for data the whole account shares once it is live ([test and live data](/docs/sandbox#test-and-live-data)). | | 403 | `test_mode_only` | Sandbox-only endpoint called with a live key. | | 403 | `tenant_suspended` | Account suspended. | | 403 | `account_closed` | The account is closed, so nothing is sent or changed (test and live keys alike; its keys are revoked as well). See [closing your account](/docs/teams#closing-your-account). | | 403 | `email_unconfirmed` | The account's email isn't confirmed yet: no new keys, webhooks or integrations until it is. Enter the emailed code in the console. | | 403 | `forbidden` | Key is valid but can't act on this resource (e.g. a `from` you don't own). | | 403 | `insufficient_scope` | A restricted key called outside its scopes; `required_scope` names the one needed. See [scopes](/docs/authentication). | | 402 | `insufficient_credits` | Your [credit balance](/docs/credits) cannot cover the call; nothing was done or charged. The body carries `balance_micro_usd`, `required_micro_usd` and `top_up_url`. | | 403 | `spend_limit_reached` | Your monthly [spend limit](/docs/spend-limits) is reached; the message was not sent or charged. | | 403 | `recipient_opted_out` | The recipient opted out of your messages. The body says when and how. See [Opt-out](/docs/opt-out). | | 403 | `quiet_hours` | A single marketing send (or any send with `"quiet_hours": "block"`) outside 8:00am to 9:00pm in the recipient's local time (8:00pm in Florida, Maryland and Oklahoma). Other messages are held, not refused. The body carries `recipient_local_time`, `timezone` and `send_after`; nothing was sent or charged. See [quiet hours](/docs/compliance-checks#quiet-hours). | | 403 | `consent_required` | No opt-in on file for this message: a marketing message, or a recurring message from a number linked to a registration (which needs an opt-in for that registration); nothing was sent or charged. Record the opt-in (consent API with `campaign_id`, the console's Record opt-in, contact import, or a START reply), or, for a non-marketing message, send `consent` with it. See [opt-in on file](/docs/compliance-checks#opt-in-on-file). | | 403 | `campaign_mismatch` | The message is outside the sending number's registration: promotional wording or `"category": "marketing"` on a non-marketing registration, anything but a one-time code from a two-factor registration, or a kind of message our registration check found doesn't match (`mismatch: "off_use_case"`, with `checked_by: "consistency_check"` and `fingerprint`). Nothing was sent or charged. The body carries `registration_id`, `use_case` and `mismatch`. See [which messages are marketing](/docs/compliance-checks#which-messages-are-marketing). | | 403 | `reconfirmation_pending` | A marketing message to someone your registered number has not texted in 30 days: we asked them to confirm first and are holding this message (`held: true`), or the question is still open (`held: false`). See [re-confirmation](/docs/compliance-checks#re-confirmation-after-30-days). | | 403 | `reconfirmation_expired` | They did not answer the re-confirmation within 7 days; marketing to them is refused until they opt in again. | | 403 | `held_for_review` | A new kind of message from an account in its first 30 days of live sending: held for our team to review (`held: true`, with `fingerprint`, `registration_id` and `expires_at`), or not held because 10,000 of the same kind already wait (`held: false`). Nothing was charged. See [review of new kinds of message](/docs/compliance-checks#review-of-new-kinds-of-message). | | 403 | `review_rejected` | Our team reviewed this kind of message for the sending number's registration and did not approve it; the reason is in the message. Nothing was sent or charged. | | 403 | `campaign_paused` | Marketing from this number is paused because more than 4% of its registration's recipients opted out in 24 hours; nothing was sent or charged. Transactional messages still go out. See [opt-out rate](/docs/compliance-checks#opt-out-rate). | | 403 | `account_paused` | All live sending from your account is paused while our team reviews messages that were blocked as phishing, impersonation or fraud (three to two or more people within 7 days). Your keys and data still work; batches and scheduled messages wait. Nothing was sent or charged. Reply to our email to appeal. | | 403 | `sender_rotation` | This person was texted from two of your other numbers in the last 24 hours, so it was not sent from a third (carriers treat it as filter evasion). Text them from one of those numbers; a reply to someone who texted this number is never refused. Nothing was sent or charged. | | 403 | `recipient_not_verified` | A free account sent to a number it has not verified. | | 403 | `sender_not_registered` | The `from` number is not linked to an approved registration yet, so it can only text your verified numbers. `X-TextKit-Sender-State` says where it stands. See [Numbers](/docs/numbers#sender-status). | | 404 | `not_found` | No such resource on your account, or it belongs to the other mode: a test key sees only sandbox data, a live key only live data ([test and live data](/docs/sandbox#test-and-live-data)). | | 405 | `method_not_allowed` | The path exists but not for this HTTP method. The `Allow` header lists the ones it supports | | 409 | `idempotency_conflict` | Idempotency-Key reused with a different payload. | | 409 | `idempotency_in_progress` | The first request with this key is still running; retry after `Retry-After` (the SDKs do). | | 409 | `verification_already_approved` | The verification was already approved; send a new code to verify again. | | 429 | `rate_limited` | Too many requests, or the sending number's registration is over its [sending pace](/docs/compliance-checks#sending-pace); check `Retry-After`. | | 429 | `quota_exceeded` | A daily quota was reached. | | 503 | `screen_unavailable` | Our content screen could not check the message just now, so it was not sent or charged (we never send a message we could not check). Send it again after `Retry-After`; batches and scheduled messages wait and retry for about 15 minutes before giving up. See [content screen](/docs/compliance-checks). | | 503 | `channel_unavailable` | [Verify](/docs/verify#by-email) email codes cannot go out right now. Nothing was sent or charged; send a text instead. | | 502 | `carrier_error` | The carrier permanently rejected the message, or did not answer after we handed it over (so we will not risk sending it twice). Temporary carrier problems do not return this: the send answers `202` with the message `queued` and we retry it ([Messages](/docs/messages#automatic-retries)). | | 500 | `internal_error` | Our fault. Retry, and tell us if it persists. | ## Delivery failures A message that was accepted but not delivered has `status: "failed"` and a `failure` object, on the message and in the `message.failed` webhook: `{ code, title, explanation, action, carrier_code }`. The codes are stable; switch on `code`, show `title` and `explanation` to your team, and follow `action`. `carrier_code` is the carrier's own code when it sent one, for support conversations. | Code | Title | What happened, and what to do | | --- | --- | --- | | `carrier_filtered` | Filtered as spam | Their carrier flagged it as spam, so it never reached the phone. **Recommended:** say who you are up front, drop link shorteners and all-caps, and change wording that keeps getting filtered. | | `unreachable` | Phone unreachable | The phone was off, out of coverage or full, and the carrier gave up. **Recommended:** retry in a few hours, and confirm the number if it keeps failing. | | `invalid_number` | Number not in service | That number doesn't exist or isn't in service. **Recommended:** stop texting it and ask for a current number. | | `opted_out` | Recipient opted out | They opted out of your texts, so we didn't send it. **Recommended:** don't retry. They can opt back in by texting START. | | `campaign_mismatch` | Outside the registration's use case | This number's registration doesn't cover this kind of message, so we didn't send it. **Recommended:** send it from a number registered for it, or cut the promotional wording. | | `content_blocked` | Content blocked | Carriers don't allow this content, usually a banned topic or a blocked link. **Recommended:** change the wording or link (see the Messaging Policy) and send again. | | `screen_unavailable` | Content check unavailable | Our content check was briefly down, so we didn't send it. That's on us. **Recommended:** send it again after Retry-After. Batches and scheduled sends retry on their own for 15 minutes. | | `rate_limited` | Sending too fast | Too many texts went out too fast for this number, registration or carrier, so we held this one. **Recommended:** retry with backoff and honor Retry-After. If a carrier's daily cap was hit, try tomorrow. | | `landline` | Number can't receive texts | That's a landline or another line that can't get texts. **Recommended:** get a mobile number. GET /v1/lookup shows the line type before you send. | | `carrier_rejected` | Rejected by the carrier | The carrier refused it, usually because this sender isn't allowed to reach this number. **Recommended:** check the sending number is set up for this kind of message. If one carrier rejects everything, send us the message id. | | `spend_limit_reached` | Spending limit reached | It would have gone over your monthly spend limit, so we didn't send it. **Recommended:** raise the limit in Settings (or PATCH /v1/spend-limit) and send it again. | | `insufficient_credits` | Out of credits | You're out of credit, so we didn't send it. **Recommended:** add credits in Billing, or turn on auto-recharge, and send it again. | | `sender_not_registered` | Sending number not registered yet | Carriers haven't approved this number yet, so it can only text numbers you've verified. **Recommended:** open the number in Numbers to see where approval stands, and send again once it's Active. | | `quiet_hours` | Held back for quiet hours | It was outside 8am to 9pm (8pm in Florida, Maryland and Oklahoma) in the recipient's time zone, so we didn't send it. **Recommended:** send it after send_after, or pass quiet_hours: "defer" and we'll send it when the window opens. | | `consent_required` | No opt-in on file | Marketing and recurring texts need an opt-in on file, and this recipient has none. **Recommended:** record it first with POST /v1/consent/{phone}. For non-marketing texts, pass "consent": {"source", "collected_at"} instead. | | `reconfirmation_pending` | Waiting for the recipient to confirm | You hadn't texted them in 30 days, so we asked them to confirm they still want marketing. **Recommended:** nothing. It sends if they reply YES within 7 days. | | `reconfirmation_expired` | Recipient did not confirm | They didn't confirm they still want marketing within 7 days, so we dropped it. **Recommended:** don't retry. Marketing resumes if they reply YES or START, or you record a new opt-in. | | `held_for_review` | Waiting for review | In your first 30 days live we review each new kind of message, and this one is waiting on us. **Recommended:** nothing. We usually review within a business day, then it sends. | | `review_rejected` | Not approved in review | We didn't approve this kind of message for this registration. The reason is in your audit log. **Recommended:** match what you send to the registration, or email support if we got it wrong. | | `review_expired` | Review did not finish in time | We didn't review it within 3 days, so we dropped it. That's on us. **Recommended:** send it again. | | `campaign_paused` | Paused for this registration | Sending from this registration is paused, either for over 4% opt-outs in 24 hours or for our review. **Recommended:** make sure everyone you text opted in, then email support to resume. | | `sender_rotation` | Too many of your numbers | Two of your other numbers already texted this person in the last 24 hours, and carriers read a third as dodging filters. **Recommended:** text each person from one number. A pool with sticky sender does this for you. | | `recipient_not_verified` | Not a verified test recipient | Free accounts can only text verified numbers, and this isn't one. **Recommended:** verify it under Home, Test recipients, or add credits in Billing to text anyone. | | `quota_exceeded` | Message allowance reached | Your daily or monthly message allowance ran out, so we didn't send it. **Recommended:** wait for the reset (midnight UTC daily, the 1st monthly) or add credits, then send it again. | | `destination_not_allowed` | Destination not served | We only text US and Canadian numbers. **Recommended:** remove the number, or tell us which country you need. | | `account_closed` | Account closed | This account is closed, so it can't send. **Recommended:** if we closed it by mistake, email support before the data is deleted. | | `account_suspended` | Account suspended | This account is suspended, so it can't send. **Recommended:** email support. We'll tell you why and how to restore it. | | `account_paused` | Sending paused for review | Live sending is paused while we review messages blocked as phishing, impersonation or fraud. **Recommended:** reply to our email to appeal, saying who sent them and why. | | `topic_unsubscribed` | Unsubscribed from this topic | They unsubscribed from this topic, so we didn't send it. **Recommended:** don't retry. They still get your other topics. | | `unknown` | Not delivered | The carrier says it wasn't delivered and won't say why. **Recommended:** retry once later. If it keeps failing, send us the message id. | Some sends are refused by one of our [checks](/docs/compliance-checks) before any carrier sees them: the recipient opted out, it is quiet hours for a marketing message, the sending number is not registered yet, the content is blocked, the number is rate limited, or the send would pass your [spending limit](/docs/spend-limits) or your credit balance. Those answer `402`, `403` or `429`, the error carries the same `failure` object, and the attempt is stored as a message with `status: "blocked"` (its id is `message_id` in the error), so one handler covers both and your log shows every attempt: ```json { "error": { "code": "recipient_opted_out", "message": "This recipient opted out of your messages on Oct 5, 2026 at 8:42 AM UTC by replying STOP. Nothing was sent and you were not charged. They can opt back in by texting START to your number.", "param": "to", "opted_out_at": "2026-10-05T08:42:00.000Z", "opted_out_via": "sms_keyword", "opted_out_method": "keyword", "failure": { "code": "opted_out", "title": "Recipient opted out", "explanation": "...", "action": "Recommended: ...", "carrier_code": null } } } ``` Every code except `content_blocked`, `screen_unavailable`, `spend_limit_reached`, `insufficient_credits` and `sender_not_registered` (sandbox numbers are never registered) has a [sandbox number](/docs/sandbox) that produces it, so you can test the handling before going live. ## Success codes `200` and `201` mean done. `202` (on `POST /v1/messages`) means accepted and `queued`: a temporary carrier problem is being retried for you. A replayed idempotent request returns the original status code with an `Idempotent-Replayed: true` header. ## Rate limits Default 60 requests/minute per key. `429`s include a `Retry-After` header. Successful quota-limited calls include `X-Quota-Remaining`. --- # Pricing | | Price | | | --- | --- | --- | | Outbound SMS | **$0.0032** | per segment, plus carrier fees | | Inbound SMS | **$0.004** | per message | | Phone number | **$0.95** | per number, per month | | Carrier lookup | **$0.008** | per lookup | | Phone verification | **$0.025** | per successful verification | US & Canada. Prepaid in USD: you buy credits and usage draws them down. Outbound texts are billed per segment (160 plain characters, or 70 with an emoji or other special character; longer texts are split into 153- or 67- character segments). Carrier fees are passed through at cost, per segment, as the recipient's carrier charges them: | Recipient's carrier | Local number | Toll-free number | | --- | --- | --- | | AT&T | $0.0035 | $0.0035 | | T-Mobile | $0.0045 | $0.0045 | | Verizon | $0.005 | $0.005 | | U.S. Cellular | $0.005 | $0.0045 | | Other carriers | $0.0045 | $0.0045 | Credits are drawn when the carrier accepts the message: failed sends cost nothing, fees included. We file your A2P 10DLC registration for you. Registration fees (brand, campaign and vetting fees) are not part of these rates: they are billed on their own invoice, as [Terms §4.3](/terms) sets out. Toll-free verification has no fee. Carriers usually review a registration in 3-7 business days, and you can text your own verified numbers the same day. ## Free tier The free tier needs no credit card: - **100 live outbound SMS per month** (10 per day) - **1 phone number** - Live texts go only to numbers you've verified (up to 3) - **Unlimited sandbox**: every endpoint, no caps, no card, no verification - 30-day log retention: messages, the event log, conversations and API request logs older than 30 days are deleted daily. Opt-out and opt-in records, registrations, billing records and the audit log are kept as the [Data Retention](/data-retention) page says Add credits to lift every cap, text anyone, keep your logs for the life of the account, and unlock live carrier lookups. ## How billing works Billing is prepaid. You buy [credits](/docs/credits) with a card (from $5), usage draws your balance down, and a call the balance cannot cover is refused with `402 insufficient_credits` rather than billed later. Credits do not expire while your account is open. There is no platform fee and no commitment; a month with no traffic costs nothing. Each month you get a statement that itemises usage, paid from your credits. Every response that consumes quota returns `X-Quota-Remaining`. When you run out on the free tier you get a `429` with code `quota_exceeded` and a message saying whether it was the daily or the monthly limit. Want a hard ceiling? Set a monthly [spend limit](/docs/spend-limits) with alerts at the thresholds you choose. Sandbox (test keys) is unlimited and free forever. It is never metered, never capped, and never requires a verified recipient; build the whole integration before you spend anything. See [the pricing page](/pricing) for the full rate table and a cost estimator. --- # Quickstarts by language Send your first SMS from the stack you already use. Each guide takes about five minutes with a free test key, and opens with a prompt you can paste into your coding agent. Node.js and Python have an SDK (`jointextkit` on npm and PyPI). Every other language calls the REST API with its standard HTTP client: one POST to `https://api.jointextkit.com/v1/messages`. ## Languages | Guide | Uses | | --- | --- | | [Node.js](/docs/send-with-nodejs) | `jointextkit` (npm) | | [Python](/docs/send-with-python) | `jointextkit` (PyPI) | | [PHP](/docs/send-with-php) | REST API, no package | | [Ruby](/docs/send-with-ruby) | REST API, no package | | [Go](/docs/send-with-go) | REST API, no package | | [Java](/docs/send-with-java) | REST API, no package | | [.NET](/docs/send-with-dotnet) | REST API, no package | | [Rust](/docs/send-with-rust) | REST API, no package | ## JavaScript frameworks and runtimes | Guide | Uses | | --- | --- | | [Next.js](/docs/send-with-nextjs) | `jointextkit` (npm) | | [Express](/docs/send-with-express) | `jointextkit` (npm) | | [Hono](/docs/send-with-hono) | `jointextkit` (npm) | | [SvelteKit](/docs/send-with-sveltekit) | `jointextkit` (npm) | | [Nuxt](/docs/send-with-nuxt) | `jointextkit` (npm) | | [Bun](/docs/send-with-bun) | `jointextkit` (npm) | | [Deno](/docs/send-with-deno) | `jointextkit` (npm) | ## Serverless | Guide | Uses | | --- | --- | | [Cloudflare Workers](/docs/send-with-cloudflare-workers) | `jointextkit` (npm) | | [Supabase Edge Functions](/docs/send-with-supabase) | `jointextkit` (npm) | | [AWS Lambda](/docs/send-with-aws-lambda) | `jointextkit` (npm) | ## Python frameworks | Guide | Uses | | --- | --- | | [Flask](/docs/send-with-flask) | `jointextkit` (PyPI) | | [FastAPI](/docs/send-with-fastapi) | `jointextkit` (PyPI) | | [Django](/docs/send-with-django) | `jointextkit` (PyPI) | ## PHP and Ruby frameworks | Guide | Uses | | --- | --- | | [Laravel](/docs/send-with-laravel) | REST API, no package | | [Rails](/docs/send-with-rails) | REST API, no package | Using another language? The request is the same everywhere: see the [Quickstart](/docs/quickstart) for it in cURL, and [Send an SMS](/docs/api/messages/send) for every field. --- # Send SMS with Node.js Send your first text from Node.js with the TextKit SDK. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Node.js project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-nodejs.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `npm install jointextkit`, then `new TextKit().messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Node.js 18 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash npm install jointextkit ``` ## 2. Send an SMS Create `send.mjs`: ```javascript import { TextKit } from 'jointextkit'; const sms = new TextKit(); // reads TEXTKIT_API_KEY const message = await sms.messages.send({ to: '+15005550006', body: 'Hello from TextKit', }); console.log(message.id, message.status); ``` ## 3. Run it ```bash node send.mjs ``` It prints the message ID (`msg_...`) and `delivered`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. ```javascript import { TextKit, TextKitError } from 'jointextkit'; try { await sms.messages.send({ to: '+15005550006', body: 'Hello from TextKit' }); } catch (err) { if (err instanceof TextKitError) { // err.code: 'invalid_request', 'rate_limited', ... err.status: the HTTP status console.error(err.status, err.code, err.message); } else { throw err; } } ``` | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Python Send your first text from Python with the TextKit SDK. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Python project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-python.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `pip install jointextkit`, then `TextKit().messages.send(to=..., body=...)`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Python 3.8 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash pip install jointextkit ``` ## 2. Send an SMS Create `send.py`: ```python from jointextkit import TextKit client = TextKit() # reads TEXTKIT_API_KEY message = client.messages.send( to="+15005550006", body="Hello from TextKit", ) print(message["id"], message["status"]) ``` ## 3. Run it ```bash python send.py ``` It prints the message ID (`msg_...`) and `delivered`. Every call returns a plain dict. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK raises `TextKitError` with the API's `code` and `status` (plus `param` for a bad field and `retry_after` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. ```python from jointextkit import TextKit, TextKitError client = TextKit() try: client.messages.send(to="+15005550006", body="Hello from TextKit") except TextKitError as err: # err.code: "invalid_request", "rate_limited", ... err.status: the HTTP status print(err.status, err.code, err) ``` | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with PHP Send your first text from PHP with the cURL extension and TextKit's REST API. No package to install. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this PHP project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-php.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - There is no PHP SDK: POST JSON to https://api.jointextkit.com/v1/messages with the cURL extension or Guzzle. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **PHP 7.4 or later with the cURL extension (on by default in most builds).** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Send an SMS Create `send.php`: ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('TEXTKIT_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'to' => '+15005550006', 'body' => 'Hello from TextKit', ]), ]); $data = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status >= 400) { fwrite(STDERR, $data['error']['code'] . ': ' . $data['error']['message'] . PHP_EOL); exit(1); } echo $data['id'] . ' ' . $data['status'] . PHP_EOL; ``` ## 2. Run it ```bash php send.php ``` It prints the message ID (`msg_...`) and `delivered`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The script above already stops on an error and prints its code. A failed request returns a 4xx status and a JSON body `{"error": {"code": "...", "message": "...", "param": "..."}}`. On `429 rate_limited`, wait the `Retry-After` seconds and try again. Send an `Idempotency-Key` header (any unique string per message) and a retried request returns the first result instead of sending twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Ruby Send your first text from Ruby with Net::HTTP from the standard library and TextKit's REST API. No gem to install. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Ruby project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-ruby.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - There is no Ruby gem: POST JSON to https://api.jointextkit.com/v1/messages with Net::HTTP (standard library) or Faraday. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Ruby 2.7 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Send an SMS Create `send.rb`: ```ruby require "net/http" require "json" uri = URI("https://api.jointextkit.com/v1/messages") req = Net::HTTP::Post.new(uri) req["Authorization"] = "Bearer #{ENV.fetch("TEXTKIT_API_KEY")}" req["Content-Type"] = "application/json" req.body = { to: "+15005550006", body: "Hello from TextKit" }.to_json res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } data = JSON.parse(res.body) if res.is_a?(Net::HTTPSuccess) puts "#{data["id"]} #{data["status"]}" else abort "#{data.dig("error", "code")}: #{data.dig("error", "message")}" end ``` ## 2. Run it ```bash ruby send.rb ``` It prints the message ID (`msg_...`) and `delivered`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The script above already stops on an error and prints its code. A failed request returns a 4xx status and a JSON body `{"error": {"code": "...", "message": "...", "param": "..."}}`. On `429 rate_limited`, wait the `Retry-After` seconds and try again. Send an `Idempotency-Key` header (any unique string per message) and a retried request returns the first result instead of sending twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Go Send your first text from Go with net/http and TextKit's REST API. No module to add. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Go project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-go.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - There is no Go module: POST JSON to https://api.jointextkit.com/v1/messages with net/http. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Go 1.18 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Send an SMS Create `main.go`: ```go package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" ) func main() { payload, _ := json.Marshal(map[string]string{ "to": "+15005550006", "body": "Hello from TextKit", }) req, err := http.NewRequest("POST", "https://api.jointextkit.com/v1/messages", bytes.NewReader(payload)) if err != nil { log.Fatal(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("TEXTKIT_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() var out struct { ID string `json:"id"` Status string `json:"status"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(res.Body).Decode(&out); err != nil { log.Fatal(err) } if out.Error != nil { log.Fatalf("%s: %s", out.Error.Code, out.Error.Message) } fmt.Println(out.ID, out.Status) } ``` ## 2. Run it ```bash go run main.go ``` It prints the message ID (`msg_...`) and `delivered`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The program above already stops on an error and prints its code. A failed request returns a 4xx status and a JSON body `{"error": {"code": "...", "message": "...", "param": "..."}}`. On `429 rate_limited`, wait the `Retry-After` seconds and try again. Send an `Idempotency-Key` header (any unique string per message) and a retried request returns the first result instead of sending twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Java Send your first text from Java with the JDK's HttpClient and TextKit's REST API. No dependency to add. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Java project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-java.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - There is no Java SDK: POST JSON to https://api.jointextkit.com/v1/messages with java.net.http.HttpClient. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Java 17 or later (the example uses a text block and runs as a single file).** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Send an SMS Create `SendSms.java`: ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class SendSms { public static void main(String[] args) throws Exception { String json = """ {"to": "+15005550006", "body": "Hello from TextKit"} """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.jointextkit.com/v1/messages")) .header("Authorization", "Bearer " + System.getenv("TEXTKIT_API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { System.err.println(response.statusCode() + " " + response.body()); System.exit(1); } System.out.println(response.body()); } } ``` ## 2. Run it ```bash java SendSms.java ``` It prints the message as JSON, with its `id` (`msg_...`) and `"status": "delivered"`. Parse it with the JSON library your project already uses (Jackson, Gson). ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The program above already stops on an error and prints the body. A failed request returns a 4xx status and a JSON body `{"error": {"code": "...", "message": "...", "param": "..."}}`. On `429 rate_limited`, wait the `Retry-After` seconds and try again. Send an `Idempotency-Key` header (any unique string per message) and a retried request returns the first result instead of sending twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with .NET Send your first text from C# with .NET's HttpClient and TextKit's REST API. No NuGet package to add. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this .NET project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-dotnet.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - There is no .NET SDK: POST JSON to https://api.jointextkit.com/v1/messages with HttpClient (System.Net.Http.Json). - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **.NET 6 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Create a project ```bash dotnet new console -o SendSms && cd SendSms ``` ## 2. Send an SMS Replace `Program.cs`: ```csharp using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient(); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("TEXTKIT_API_KEY")); var response = await http.PostAsJsonAsync("https://api.jointextkit.com/v1/messages", new { to = "+15005550006", body = "Hello from TextKit", }); var json = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { Console.Error.WriteLine($"{(int)response.StatusCode} {json}"); return 1; } Console.WriteLine(json); return 0; ``` ## 3. Run it ```bash dotnet run ``` It prints the message as JSON, with its `id` (`msg_...`) and `"status": "delivered"`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The program above already stops on an error and prints the body. A failed request returns a 4xx status and a JSON body `{"error": {"code": "...", "message": "...", "param": "..."}}`. On `429 rate_limited`, wait the `Retry-After` seconds and try again. Send an `Idempotency-Key` header (any unique string per message) and a retried request returns the first result instead of sending twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Rust Send your first text from Rust with reqwest and TextKit's REST API. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Rust project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-rust.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - There is no Rust crate: POST JSON to https://api.jointextkit.com/v1/messages with reqwest. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Rust 1.70 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Add the crates ```bash cargo new send-sms && cd send-sms cargo add reqwest --features json cargo add tokio --features full cargo add serde_json ``` ## 2. Send an SMS Replace `src/main.rs`: ```rust use serde_json::{json, Value}; #[tokio::main] async fn main() -> Result<(), Box> { let key = std::env::var("TEXTKIT_API_KEY")?; let res = reqwest::Client::new() .post("https://api.jointextkit.com/v1/messages") .bearer_auth(key) .json(&json!({ "to": "+15005550006", "body": "Hello from TextKit" })) .send() .await?; let status = res.status(); let body: Value = res.json().await?; let text = |v: &Value| v.as_str().unwrap_or_default().to_string(); if !status.is_success() { eprintln!("{}: {}", text(&body["error"]["code"]), text(&body["error"]["message"])); std::process::exit(1); } println!("{} {}", text(&body["id"]), text(&body["status"])); Ok(()) } ``` ## 3. Run it ```bash cargo run ``` It prints the message ID (`msg_...`) and `delivered`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The program above already stops on an error and prints its code. A failed request returns a 4xx status and a JSON body `{"error": {"code": "...", "message": "...", "param": "..."}}`. On `429 rate_limited`, wait the `Retry-After` seconds and try again. Send an `Idempotency-Key` header (any unique string per message) and a retried request returns the first result instead of sending twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Next.js Send a text from a Next.js App Router route handler with the TextKit SDK. The key stays on the server. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Next.js project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-nextjs.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK on the server only (route handler or server action): `npm install jointextkit`, then `new TextKit().messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Next.js 13.4 or later with the App Router, on the Node.js runtime.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash npm install jointextkit ``` Add the key to `.env.local` (and to your host's environment variables when you deploy): ```bash TEXTKIT_API_KEY=ssms_sk_test_... ``` Never prefix it with `NEXT_PUBLIC_`: that would ship it to the browser. ## 2. Create a route handler Create `app/api/send-sms/route.ts`: ```typescript import { TextKit, TextKitError } from 'jointextkit'; export async function POST(request: Request) { const { to, body } = await request.json(); const sms = new TextKit(); // reads TEXTKIT_API_KEY try { const message = await sms.messages.send({ to, body }); return Response.json({ id: message.id, status: message.status }); } catch (err) { if (err instanceof TextKitError) { return Response.json({ error: err.code, message: err.message }, { status: err.status }); } throw err; } } ``` The client is created inside the handler so `next build` never needs the key. ## 3. Try it ```bash npm run dev curl -X POST http://localhost:3000/api/send-sms \ -H "Content-Type: application/json" \ -d '{"to": "+15005550006", "body": "Hello from TextKit"}' ``` It returns `{"id": "msg_...", "status": "delivered"}`. A server action works the same way: call `new TextKit().messages.send(...)` inside a `'use server'` function. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Express Send a text from an Express route with the TextKit Node.js SDK. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Express project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-express.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK in your route: `npm install jointextkit`, then `new TextKit().messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Node.js 18 or later and Express 4 or 5.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install ```bash npm install express jointextkit ``` ## 2. Add a route Create `server.mjs`: ```javascript import express from 'express'; import { TextKit, TextKitError } from 'jointextkit'; const app = express(); app.use(express.json()); const sms = new TextKit(); // reads TEXTKIT_API_KEY app.post('/send-sms', async (req, res, next) => { try { const message = await sms.messages.send({ to: req.body.to, body: req.body.body }); res.json({ id: message.id, status: message.status }); } catch (err) { if (err instanceof TextKitError) { res.status(err.status).json({ error: err.code, message: err.message }); } else { next(err); } } }); app.listen(3000, () => console.log('Listening on http://localhost:3000')); ``` ## 3. Try it ```bash node server.mjs curl -X POST http://localhost:3000/send-sms \ -H "Content-Type: application/json" \ -d '{"to": "+15005550006", "body": "Hello from TextKit"}' ``` It returns `{"id": "msg_...", "status": "delivered"}`. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Hono Send a text from a Hono route with the TextKit SDK. The same code runs on Node.js, Bun, Deno and Cloudflare Workers. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Hono project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-hono.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `npm install jointextkit`, read the key with `env(c)` from `hono/adapter`, then `new TextKit(key).messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Hono 4 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash npm install jointextkit ``` ## 2. Add a route ```typescript import { Hono } from 'hono'; import { env } from 'hono/adapter'; import { TextKit, TextKitError } from 'jointextkit'; const app = new Hono(); app.post('/send-sms', async (c) => { const { TEXTKIT_API_KEY } = env<{ TEXTKIT_API_KEY: string }>(c); const { to, body } = await c.req.json(); const sms = new TextKit(TEXTKIT_API_KEY); try { const message = await sms.messages.send({ to, body }); return c.json({ id: message.id, status: message.status }); } catch (err) { if (err instanceof TextKitError) { return Response.json({ error: err.code, message: err.message }, { status: err.status }); } throw err; } }); export default app; ``` `env(c)` reads the key from wherever the runtime keeps it: `process.env` on Node.js and Bun, `Deno.env` on Deno, the Worker's bindings on Cloudflare (set it with `npx wrangler secret put TEXTKIT_API_KEY`). ## 3. Try it ```bash curl -X POST http://localhost:3000/send-sms \ -H "Content-Type: application/json" \ -d '{"to": "+15005550006", "body": "Hello from TextKit"}' ``` Use your dev server's port. It returns `{"id": "msg_...", "status": "delivered"}`. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with SvelteKit Send a text from a SvelteKit server route with the TextKit SDK. The key stays in private env. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this SvelteKit project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-sveltekit.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK in a `+server.ts` route only: `npm install jointextkit`, key from `$env/dynamic/private`, then `new TextKit(key).messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **SvelteKit 2 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash npm install jointextkit ``` Add `TEXTKIT_API_KEY=ssms_sk_test_...` to `.env`. SvelteKit keeps variables without the `PUBLIC_` prefix on the server. ## 2. Add a server route Create `src/routes/api/send-sms/+server.ts`: ```typescript import { json } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { TextKit, TextKitError } from 'jointextkit'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request }) => { const { to, body } = await request.json(); const sms = new TextKit(env.TEXTKIT_API_KEY); try { const message = await sms.messages.send({ to, body }); return json({ id: message.id, status: message.status }); } catch (err) { if (err instanceof TextKitError) { return json({ error: err.code, message: err.message }, { status: err.status }); } throw err; } }; ``` ## 3. Try it ```bash npm run dev curl -X POST http://localhost:5173/api/send-sms \ -H "Content-Type: application/json" \ -d '{"to": "+15005550006", "body": "Hello from TextKit"}' ``` It returns `{"id": "msg_...", "status": "delivered"}`. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Nuxt Send a text from a Nuxt server route with the TextKit SDK. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Nuxt project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-nuxt.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK in `server/api` only: `npm install jointextkit`, then `new TextKit().messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Nuxt 3 or later, deployed to a Node.js server.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash npm install jointextkit ``` Add `TEXTKIT_API_KEY=ssms_sk_test_...` to `.env`. ## 2. Add a server route Create `server/api/send-sms.post.ts`: ```typescript import { TextKit, TextKitError } from 'jointextkit'; export default defineEventHandler(async (event) => { const { to, body } = await readBody(event); const sms = new TextKit(); // reads TEXTKIT_API_KEY try { const message = await sms.messages.send({ to, body }); return { id: message.id, status: message.status }; } catch (err) { if (err instanceof TextKitError) { throw createError({ statusCode: err.status, statusMessage: err.code, message: err.message }); } throw err; } }); ``` ## 3. Try it ```bash npm run dev curl -X POST http://localhost:3000/api/send-sms \ -H "Content-Type: application/json" \ -d '{"to": "+15005550006", "body": "Hello from TextKit"}' ``` It returns `{"id": "msg_...", "status": "delivered"}`. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Bun Send your first text from Bun with the TextKit SDK. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Bun project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-bun.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `bun add jointextkit`, then `new TextKit().messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Bun 1.0 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash bun add jointextkit ``` Put `TEXTKIT_API_KEY=ssms_sk_test_...` in `.env`: Bun loads it for you. ## 2. Send an SMS Create `send.ts`: ```typescript import { TextKit } from 'jointextkit'; const sms = new TextKit(); // reads TEXTKIT_API_KEY const message = await sms.messages.send({ to: '+15005550006', body: 'Hello from TextKit', }); console.log(message.id, message.status); ``` ## 3. Run it ```bash bun send.ts ``` It prints the message ID (`msg_...`) and `delivered`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. ```javascript import { TextKit, TextKitError } from 'jointextkit'; try { await sms.messages.send({ to: '+15005550006', body: 'Hello from TextKit' }); } catch (err) { if (err instanceof TextKitError) { // err.code: 'invalid_request', 'rate_limited', ... err.status: the HTTP status console.error(err.status, err.code, err.message); } else { throw err; } } ``` | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Deno Send your first text from Deno with the TextKit SDK, imported straight from npm. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Deno project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-deno.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `import { TextKit } from 'npm:jointextkit'`, then `new TextKit(Deno.env.get('TEXTKIT_API_KEY')).messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Deno 1.28 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Send an SMS Create `send.ts`: ```typescript import { TextKit } from 'npm:jointextkit'; const sms = new TextKit(Deno.env.get('TEXTKIT_API_KEY')); const message = await sms.messages.send({ to: '+15005550006', body: 'Hello from TextKit', }); console.log(message.id, message.status); ``` ## 2. Run it ```bash deno run --allow-net --allow-env send.ts ``` It prints the message ID (`msg_...`) and `delivered`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. Import `TextKitError` from `npm:jointextkit` too. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Cloudflare Workers Send a text from a Cloudflare Worker with the TextKit SDK. The key lives in a Worker secret. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Cloudflare Workers project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-cloudflare-workers.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `npm install jointextkit`, pass the key from the Worker's `env` (a secret), then `new TextKit(env.TEXTKIT_API_KEY).messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **A Workers project created with `npm create cloudflare@latest`.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK and add the key ```bash npm install jointextkit npx wrangler secret put TEXTKIT_API_KEY ``` For `wrangler dev`, put `TEXTKIT_API_KEY=ssms_sk_test_...` in `.dev.vars`. ## 2. Send an SMS Replace `src/index.ts`: ```typescript import { TextKit, TextKitError } from 'jointextkit'; interface Env { TEXTKIT_API_KEY: string; } export default { async fetch(request: Request, env: Env): Promise { if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 }); const { to, body } = (await request.json()) as { to: string; body: string }; const sms = new TextKit(env.TEXTKIT_API_KEY); try { const message = await sms.messages.send({ to, body }); return Response.json({ id: message.id, status: message.status }); } catch (err) { if (err instanceof TextKitError) { return Response.json({ error: err.code, message: err.message }, { status: err.status }); } throw err; } }, }; ``` ## 3. Try it, then deploy ```bash npx wrangler dev curl -X POST http://localhost:8787 \ -H "Content-Type: application/json" \ -d '{"to": "+15005550006", "body": "Hello from TextKit"}' npx wrangler deploy ``` It returns `{"id": "msg_...", "status": "delivered"}`. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Supabase Edge Functions Send a text from a Supabase Edge Function with the TextKit SDK, imported from npm. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Supabase Edge Functions project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-supabase.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `import { TextKit } from 'npm:jointextkit'`, key from `Deno.env.get('TEXTKIT_API_KEY')` (a function secret), then `.messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **The Supabase CLI and a linked project.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Create the function and add the key ```bash supabase functions new send-sms supabase secrets set TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 2. Send an SMS Replace `supabase/functions/send-sms/index.ts`: ```typescript import { TextKit, TextKitError } from 'npm:jointextkit'; Deno.serve(async (req) => { const { to, body } = await req.json(); const sms = new TextKit(Deno.env.get('TEXTKIT_API_KEY')); try { const message = await sms.messages.send({ to, body }); return Response.json({ id: message.id, status: message.status }); } catch (err) { if (err instanceof TextKitError) { return Response.json({ error: err.code, message: err.message }, { status: err.status }); } throw err; } }); ``` ## 3. Deploy it ```bash supabase functions deploy send-sms ``` Edge Functions check the caller's Supabase JWT by default, so only signed-in users of your app can call it. Call it from your app with `supabase.functions.invoke('send-sms', { body: { to, body } })`. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with AWS Lambda Send a text from an AWS Lambda function (Node.js runtime) with the TextKit SDK. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this AWS Lambda project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-aws-lambda.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: bundle `jointextkit` with the function, set `TEXTKIT_API_KEY` in its environment, then `new TextKit().messages.send({ to, body })`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **A Lambda function on the Node.js 18 runtime or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash mkdir send-sms && cd send-sms npm init -y && npm install jointextkit ``` ## 2. Write the handler Create `index.mjs`: ```javascript import { TextKit } from 'jointextkit'; const sms = new TextKit(); // reads TEXTKIT_API_KEY from the function's environment export const handler = async (event) => { const { to, body } = typeof event.body === 'string' ? JSON.parse(event.body) : event; const message = await sms.messages.send({ to, body }); return { statusCode: 200, body: JSON.stringify({ id: message.id, status: message.status }) }; }; ``` ## 3. Deploy it ```bash zip -r function.zip index.mjs node_modules package.json aws lambda update-function-code --function-name send-sms --zip-file fileb://function.zip aws lambda update-function-configuration --function-name send-sms \ --environment "Variables={TEXTKIT_API_KEY=ssms_sk_test_...}" ``` Invoke it with `{"to": "+15005550006", "body": "Hello from TextKit"}` as the test event. It returns `{"id": "msg_...", "status": "delivered"}`. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK throws a `TextKitError` with the API's `code`, `status` and `message` (plus `param` for a bad field and `retryAfter` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Flask Send a text from a Flask route with the TextKit Python SDK. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Flask project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-flask.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK in your view: `pip install jointextkit`, then `TextKit().messages.send(to=..., body=...)`. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Python 3.8 or later and Flask 2 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install ```bash pip install flask jointextkit ``` ## 2. Add a route Create `app.py`: ```python from flask import Flask, jsonify, request from jointextkit import TextKit, TextKitError app = Flask(__name__) client = TextKit() # reads TEXTKIT_API_KEY @app.post("/send-sms") def send_sms(): data = request.get_json() try: message = client.messages.send(to=data["to"], body=data["body"]) except TextKitError as err: return jsonify(error=err.code, message=str(err)), err.status return jsonify(id=message["id"], status=message["status"]) ``` ## 3. Try it ```bash flask --app app run curl -X POST http://localhost:5000/send-sms \ -H "Content-Type: application/json" \ -d '{"to": "+15005550006", "body": "Hello from TextKit"}' ``` It returns `{"id": "msg_...", "status": "delivered"}`. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK raises `TextKitError` with the API's `code` and `status` (plus `param` for a bad field and `retry_after` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with FastAPI Send a text from a FastAPI endpoint with the TextKit Python SDK. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this FastAPI project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-fastapi.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `pip install jointextkit`, then `TextKit().messages.send(to=..., body=...)` in a sync (`def`) endpoint so FastAPI runs it in its thread pool. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Python 3.8 or later and FastAPI.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install ```bash pip install "fastapi[standard]" jointextkit ``` ## 2. Add an endpoint Create `main.py`: ```python from fastapi import FastAPI, HTTPException from jointextkit import TextKit, TextKitError from pydantic import BaseModel app = FastAPI() client = TextKit() # reads TEXTKIT_API_KEY class Sms(BaseModel): to: str body: str @app.post("/send-sms") def send_sms(sms: Sms): try: message = client.messages.send(to=sms.to, body=sms.body) except TextKitError as err: raise HTTPException(status_code=err.status, detail={"code": err.code, "message": str(err)}) return {"id": message["id"], "status": message["status"]} ``` The endpoint is a plain `def`: the SDK is synchronous, and FastAPI runs sync endpoints in a thread pool so they never block the event loop. ## 3. Try it ```bash fastapi dev main.py curl -X POST http://localhost:8000/send-sms \ -H "Content-Type: application/json" \ -d '{"to": "+15005550006", "body": "Hello from TextKit"}' ``` It returns `{"id": "msg_...", "status": "delivered"}`. > This endpoint texts whatever number it receives. Put it behind your own > sign-in before you deploy it, or call `send` from code that runs after a real > event (a signup, an order). An open endpoint lets anyone text through your > account. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK raises `TextKitError` with the API's `code` and `status` (plus `param` for a bad field and `retry_after` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Django Send a text from Django with the TextKit Python SDK: from a management command to test, then from your views and tasks. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Django project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-django.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - Use the official SDK: `pip install jointextkit`, then `TextKit().messages.send(to=..., body=...)` from views, signals or background tasks. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Python 3.8 or later and Django 4 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Install the SDK ```bash pip install jointextkit ``` ## 2. Send a test from a management command Create `yourapp/management/commands/send_test_sms.py` (with empty `__init__.py` files in `management/` and `management/commands/`): ```python from django.core.management.base import BaseCommand, CommandError from jointextkit import TextKit, TextKitError class Command(BaseCommand): help = "Send a test SMS with TextKit" def add_arguments(self, parser): parser.add_argument("to") def handle(self, *args, **options): try: message = TextKit().messages.send(to=options["to"], body="Hello from TextKit") except TextKitError as err: raise CommandError(f"{err.code}: {err}") self.stdout.write(f'{message["id"]} {message["status"]}') ``` ```bash python manage.py send_test_sms +15005550006 ``` It prints the message ID (`msg_...`) and `delivered`. ## 3. Send from your app Call the same line wherever something happens that deserves a text: ```python from jointextkit import TextKit sms = TextKit() # reads TEXTKIT_API_KEY def order_shipped(order): sms.messages.send(to=order.phone, body=f"Order {order.number} shipped.") ``` Run it from a background task (Celery, Django-Q, RQ) when the request should not wait on the network. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors The SDK raises `TextKitError` with the API's `code` and `status` (plus `param` for a bad field and `retry_after` on a rate limit). It retries network failures and `429`s for you, with an idempotency key, so a retry never sends twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Laravel Send a text from Laravel with its built-in HTTP client and TextKit's REST API. No package to install. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Laravel project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-laravel.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - There is no Laravel package: POST JSON to https://api.jointextkit.com/v1/messages with `Http::withToken(...)` (Laravel's HTTP client). - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Laravel 10 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Add the key to config In `.env`: ```bash TEXTKIT_API_KEY=ssms_sk_test_... ``` In `config/services.php`: ```php 'textkit' => [ 'key' => env('TEXTKIT_API_KEY'), ], ``` ## 2. Send a test from Artisan Add to `routes/console.php`: ```php use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Http; Artisan::command('sms:test {to}', function (string $to) { $message = Http::withToken(config('services.textkit.key')) ->post('https://api.jointextkit.com/v1/messages', [ 'to' => $to, 'body' => 'Hello from TextKit', ]) ->throw() ->json(); $this->info($message['id'] . ' ' . $message['status']); }); ``` ```bash php artisan sms:test +15005550006 ``` It prints the message ID (`msg_...`) and `delivered`. Use the same `Http::withToken(...)->post(...)` call in a job or a controller. ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors `->throw()` raises `Illuminate\Http\Client\RequestException` on a 4xx or 5xx; `$e->response->json('error.code')` is the API's code. A failed request returns a 4xx status and a JSON body `{"error": {"code": "...", "message": "...", "param": "..."}}`. On `429 rate_limited`, wait the `Retry-After` seconds and try again. Send an `Idempotency-Key` header (any unique string per message) and a retried request returns the first result instead of sending twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks. --- # Send SMS with Rails Send a text from Rails with a small service object over TextKit's REST API. No gem to install. ## Prompt for your AI agent Paste this into Claude Code, Cursor or any coding agent to have it do the integration: ```text Add SMS sending to this Rails project with TextKit. - Docs for this stack: https://jointextkit.com/docs/send-with-rails.md - API: POST https://api.jointextkit.com/v1/messages with `Authorization: Bearer ` and a JSON body `{"to": "+1...", "body": "..."}`. - There is no Ruby gem: POST JSON to https://api.jointextkit.com/v1/messages with Net::HTTP from a service object, and call it from an Active Job. - Read the key from the `TEXTKIT_API_KEY` environment variable. Never hardcode it or send it to the browser. - Phone numbers are E.164 and US or Canada only (`+1` followed by 10 digits). - A test key (`ssms_sk_test_`) simulates: `+15005550006` always ends `delivered` and no phone gets a text. - Errors are JSON: `{"error": {"code", "message", "param"}}` with a 4xx status. On `429`, wait `Retry-After` seconds and retry. - Send only from server code that runs after a real event (a signup, an order). Never expose an endpoint that texts any number a visitor types. ``` ## Before you start - **An API key.** [Create a free account](/signup) and copy the test key (`ssms_sk_test_...`) from Home in the console. It works at once, no card. - **Rails 6 or later.** - Put the key in an environment variable: ```bash export TEXTKIT_API_KEY=ssms_sk_test_... ``` ## 1. Add a service object Create `app/services/textkit.rb`: ```ruby require "net/http" class Textkit ENDPOINT = URI("https://api.jointextkit.com/v1/messages") class Error < StandardError; end def self.send_sms(to:, body:) req = Net::HTTP::Post.new(ENDPOINT) req["Authorization"] = "Bearer #{ENV.fetch("TEXTKIT_API_KEY")}" req["Content-Type"] = "application/json" req.body = { to: to, body: body }.to_json res = Net::HTTP.start(ENDPOINT.hostname, ENDPOINT.port, use_ssl: true) { |http| http.request(req) } data = JSON.parse(res.body) raise Error, "#{data.dig("error", "code")}: #{data.dig("error", "message")}" unless res.is_a?(Net::HTTPSuccess) data end end ``` ## 2. Send a test ```bash bin/rails runner 'puts Textkit.send_sms(to: "+15005550006", body: "Hello from TextKit")["id"]' ``` It prints the message ID (`msg_...`). ## 3. Send from a job ```ruby class SendSmsJob < ApplicationJob queue_as :default def perform(to, body) Textkit.send_sms(to: to, body: body) end end ``` ```ruby SendSmsJob.perform_later(user.phone, "Your order shipped.") ``` ## What the test key did `+15005550006` is a sandbox number: the send comes back `delivered` and no phone gets a text (the response says `"simulated": true`). Put your own verified phone in `to` and the test key texts it for real. See [Sandbox & test numbers](/docs/sandbox). ## Handle errors `Textkit.send_sms` raises `Textkit::Error` with the API's code. A failed request returns a 4xx status and a JSON body `{"error": {"code": "...", "message": "...", "param": "..."}}`. On `429 rate_limited`, wait the `Retry-After` seconds and try again. Send an `Idempotency-Key` header (any unique string per message) and a retried request returns the first result instead of sending twice. | Status | Code | What to do | | --- | --- | --- | | 400 | `invalid_request` | A field is missing or malformed; `param` names it. | | 401 | `invalid_api_key` | The key is missing, revoked or mistyped. | | 402 | `insufficient_credits` | Add credits; nothing was sent or charged. | | 403 | `recipient_opted_out` | They texted STOP. Do not retry. | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry. | Every code is in [Errors](/docs/errors). ## Go live 1. **Add a number** in the [console](/console). 2. **Register your use case.** We file your 10DLC registration; toll-free numbers use a guided verification form. Until carriers approve it, the number texts only your verified numbers. See [Compliance & registration](/docs/compliance). 3. **Swap in a live key** (`ssms_sk_live_...`). The code stays the same. ## Next steps - [Webhooks](/docs/webhooks): delivery receipts and replies, pushed to your server. - [Verify](/docs/verify): one-time codes in two calls, billed only when verified. - [Send an SMS](/docs/api/messages/send): every field the endpoint takes. - [All quickstarts](/docs/quickstarts): the same guide for other languages and frameworks.