# Send an SMS

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

`POST /v1/messages`

Sends an SMS from one of your numbers. In test mode the message is simulated: it reaches `delivered` but no text is sent (`simulated: true`, an X-TextKit-Simulated header, and a `notice` when `to` is a real-looking number). The one exception is a send to your own verified phone, which is really delivered when a verification sender is available (`simulated: false`). `from` may be omitted when the account has exactly one number for the key's mode (the sandbox number with a test key, the one live sending number with a live key); with none or several the request is refused and the error lists the choices under `numbers`. Supports the Idempotency-Key header, including with scheduled_at. A live send from a US local number that is not yet linked to an approved registration (sender.state other than active) is delivered only to your verified numbers; to anyone else it answers 403 sender_not_registered with an X-TextKit-Sender-State header (test_only | pending | action_needed). The same holds for a toll-free number until its toll_free.status is verified. An account without live access that owns a live toll-free number may use a live key here: until the account is live, a live send goes only from that toll-free number to your verified numbers (billed like any live send); any other live send answers 403 sender_not_registered.

Send your API key as a bearer token: `Authorization: Bearer ssms_sk_...`. Test keys run this endpoint against the [sandbox](/docs/sandbox); see [Authentication](/docs/authentication) for key modes and scopes.

## Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | No | Makes retries safe for 24 hours: the same key and body replays the original successful response (with Idempotent-Replayed: true); a different body returns 409. Failed requests release the key. At most 255 characters. |

## Request body

JSON (`Content-Type: application/json`).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `to` | string | Yes | Example: `+15005550006`. |
| `from` | string | No | One of your numbers, or a sender pool id (pool_...): TextKit picks a pool member, the same one for each recipient every time. Optional when the account has exactly one number for the key's mode. Example: `+15005550100`. |
| `body` | string | Yes | At most 1600 characters. |
| `customer_id` | string | No | Optional. Attribute to one of your customers. Defaults to the customer the from-number is assigned to; a number assigned to another customer is rejected (400). |
| `scheduled_at` | string | No | Optional. Future ISO timestamp (or epoch ms), up to 30 days out. The message is queued and the response is a scheduled_message object instead of a message. A marketing message scheduled into the recipient's quiet hours is refused now (403 quiet_hours) or, with quiet_hours defer, moved to their next 8:00am; any other message is moved. |
| `category` | `marketing`, `transactional`, `otp` | No | Optional. It can make a message stricter (`marketing`), never looser: what a message is comes from the sending number's registration and its wording ([which messages are marketing](/docs/compliance-checks#which-messages-are-marketing)). A message outside its registration's use case is refused with 403 campaign_mismatch. |
| `quiet_hours` | `block`, `defer` | No | Optional. What to do with a message in the recipient's quiet hours: `block` refuses it (403 quiet_hours), `defer` holds it until their next 8:00am. Omitted: the account default (a single marketing send is refused; every other message is held). One-time codes are never held. |
| `shorten_links` | boolean | No | Optional. Rewrite every http(s) link in `body` to a short link on your active link domain (POST /link-domains), with clicks tracked (`links` on the message, `message.link_clicked` events). The links are checked as you wrote them; segments are counted on the shortened text. Without an active link domain the request is refused with 400 link_domain_required: short links are never put on a shared domain. Works with scheduled_at. |
| `tags` | array of string | No | Optional. Your own labels, up to 10 (letters, digits, space _ . : -; up to 32 characters each; lowercased), for example a campaign or a flow. Stored on the message, sent on its webhook events, and reported by GET /v1/deliverability?group_by=tag. Filter with GET /v1/messages?tag=. At most 10 items. |
| `consent` | object | No | Optional. How the recipient agreed to your messages. A recurring non-marketing message 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 is sent. Never counts for marketing (use POST /v1/consent with proof). Not with scheduled_at. |
| `consent.source` | `web_form`, `keyword`, `paper`, `checkout`, `verbal` | Yes |  |
| `consent.collected_at` | string | Yes | When they agreed: ISO timestamp or epoch ms, not in the future. |
| `consent.page_url` | string (uri) | No | Required for web_form: the page the form was on. |
| `media` | array of string (uri) | No | Reserved for MMS; currently returns 400 mms_not_enabled. |
| `status_callback` | string (uri) | No | Optional. Also send this message's status events (message.sent, message.delivered, message.failed, message.blocked) to this https URL, signed with your callback secret (POST /v1/webhooks/callback_secret; 400 callback_secret_required until it exists). Logged and retried like a webhook endpoint, under endpoint `status_callback`. Immediate sends only. Example: `https://example.com/sms/status`. |

## Responses

| Status | Meaning | Body |
| --- | --- | --- |
| 201 | Message accepted | `Message` |
| 202 | Accepted and not yet sent. Either a Message with status queued (a temporary carrier failure is being retried automatically, next_attempt_at set), or a ScheduledMessage with a `deferred` object (a message held for quiet hours, set for the recipient's next 8:00am). | `Message or ScheduledMessage` |
| 400 | Invalid request, or link_domain_required (`shorten_links` with no active link domain) | `Error` |
| 401 | Missing, malformed, or revoked API key | `Error` |
| 402 | Billable live calls only: the prepaid credit balance cannot cover the call (insufficient_credits); nothing was done or charged | `Error` |
| 403 | Refused by a send check: campaign_mismatch (the message is outside the sending number's registration: promotional wording or a marketing label 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 does not match; with registration_id, use_case and mismatch, plus checked_by consistency_check and fingerprint when the check refused it), recipient_opted_out, quiet_hours (with recipient_local_time, timezone, send_after), consent_required (a marketing message, or a recurring message from a registered number, to a number with no opt-in on file: for the registration of the sending number when it has one, with registration_id and consent_scope), reconfirmation_pending / reconfirmation_expired (a marketing recipient not texted in 30 days is asked to confirm first; see /docs/compliance-checks), campaign_paused (marketing paused on the registration of the sending number, for a high opt-out rate), held_for_review (a new kind of message from an account in its first 30 days of live sending, or one the registration check could not place, held for our team to review; with held, fingerprint, registration_id and expires_at), review_rejected (a kind of message our team did not approve for the registration; with fingerprint), sender_not_registered, recipient_not_verified, spend_limit_reached, or forbidden (content, destination). The attempt is stored as a message with status blocked; its id is `message_id` in the error. | `Error` |
| 409 | Idempotency conflict | `Error` |
| 429 | Rate limit or quota exceeded | `Error` |
| 502 | The carrier permanently rejected the message (carrier_error) | `Error` |
| 503 | The content screen could not check the message (screen_unavailable), so it was not sent or charged; retry after Retry-After. Stored as a blocked message; its id is `message_id` in the error. | `Error` |

### 201: `Message` fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Example: `msg_a1B2c3D4e5F6g7H8`. |
| `object` | `message` |  |
| `to` | string | Example: `+15005550006`. |
| `from` | string | Example: `+15005550100`. |
| `body` | string |  |
| `direction` | `outbound`, `inbound` |  |
| `status` | `queued`, `sent`, `delivered`, `failed`, `blocked`, `received` | `blocked`: refused by one of our checks before any carrier saw it (opt-out, quiet hours, unregistered sender, content, allowance, spend limit, credits). Final, never charged; `failure` says why. `failed`: accepted, then not delivered. |
| `test` | boolean |  |
| `simulated` | boolean | 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`. |
| `notice` | string | Only on the response to POST /messages. Present when a test-key send to a real-looking number was simulated: says nothing was sent and how to send for real. |
| `schedule_id` | string | The recurring schedule this message is an occurrence of. Present only when set. |
| `tags` | array of string | The labels sent with the message (`tags` on POST /messages), lowercased. Present only when set. |
| `category` | `marketing`, `transactional`, `otp` | How the message was classified. Present when you sent one, or when it was classified as marketing. |
| `disclosures_added` | array of `brand`, `opt_out` | Wording we added to a message from a number linked to a registration ([what we add](/docs/compliance-checks#what-we-add)): `brand`, your business name in front of a person's first message; `opt_out`, " Reply STOP to opt out." on marketing that had none. `body` is the text as sent. Present only when we added something. |
| `deferred` | object | Present when the message was held for quiet hours and sent when the window opened. |
| `deferred.reason` | `quiet_hours` |  |
| `deferred.held_at` | string (date-time) |  |
| `redacted_at` | string (date-time) | When the message was redacted (POST /messages/{id}/redact): `body` is empty and `media` is gone. Present only then. |
| `links` | array of object | Links shortened with `shorten_links`, in the order they appear. On the send response and GET /messages/{id} (with clicks so far); not on list pages. `body` is the text as sent, with the short links. |
| `links[].url` | string | The link as you wrote it. |
| `links[].short_url` | string | Example: `https://links.example.com/Ab3dEf9h`. |
| `links[].clicks` | integer | Opens by people. Link previews (a messaging app fetching the link to draw a preview) are not counted. |
| `links[].first_clicked_at` | string (date-time) | Nullable. |
| `links[].last_clicked_at` | string (date-time) | Nullable. |
| `created_at` | string (date-time) |  |
| `media` | array of string (uri) | MMS attachments (inbound today). |
| `sent_by` | string | Console user or API key that composed it. |
| `failure_reason` | string | The carrier's raw text for a failed send. Kept for compatibility; see `failure`. |
| `attempts` | integer | Live sends: carrier submissions so far. More than 1 means it was retried. |
| `next_attempt_at` | string (date-time) | Present while queued for an automatic carrier retry. |
| `receipt_status` | `missing` | The carrier accepted the message but sent no delivery report within 72 hours. status stays sent. |
| `customer_id` | string | The customer this is attributed to. Present only when set. |
| `timeline` | array of object | Every status transition, oldest first. Inbound messages have a single `received`. |
| `timeline[].status` | `accepted`, `validated`, `queued`, `sent_to_carrier`, `carrier_accepted`, `retry_scheduled`, `delivered`, `failed`, `blocked`, `received` |  |
| `timeline[].at` | string (date-time) |  |
| `timeline[].attempt` | integer | Carrier attempt this step belongs to (from sent_to_carrier on). |
| `segments` | integer | Parts the body is split into (GSM-7: 160, then 153 each; UCS-2: 70, then 67). Example: `1`. |
| `encoding` | `gsm7`, `ucs2` |  |
| `price` | object | What this message costs, in USD: the outbound rate per segment, plus the recipient's carrier fee per segment, at cost, as its own line. Null on messages sent before prices were recorded. Nullable. |
| `price.total` | number | Example: `0.0082`. |
| `price.currency` | `usd` |  |
| `price.breakdown` | array of object |  |
| `price.breakdown[].label` | string |  |
| `price.breakdown[].amount` | number |  |
| `destination_carrier` | string | The recipient's mobile carrier, when a cached lookup knows it. Nullable. |
| `failure` | Failure | Null unless status is `failed`. Nullable. |

## Errors

| Status | When |
| --- | --- |
| 400 | Invalid request, or link_domain_required (`shorten_links` with no active link domain) |
| 401 | Missing, malformed, or revoked API key |
| 402 | Billable live calls only: the prepaid credit balance cannot cover the call (insufficient_credits); nothing was done or charged |
| 403 | Refused by a send check: campaign_mismatch (the message is outside the sending number's registration: promotional wording or a marketing label 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 does not match; with registration_id, use_case and mismatch, plus checked_by consistency_check and fingerprint when the check refused it), recipient_opted_out, quiet_hours (with recipient_local_time, timezone, send_after), consent_required (a marketing message, or a recurring message from a registered number, to a number with no opt-in on file: for the registration of the sending number when it has one, with registration_id and consent_scope), reconfirmation_pending / reconfirmation_expired (a marketing recipient not texted in 30 days is asked to confirm first; see /docs/compliance-checks), campaign_paused (marketing paused on the registration of the sending number, for a high opt-out rate), held_for_review (a new kind of message from an account in its first 30 days of live sending, or one the registration check could not place, held for our team to review; with held, fingerprint, registration_id and expires_at), review_rejected (a kind of message our team did not approve for the registration; with fingerprint), sender_not_registered, recipient_not_verified, spend_limit_reached, or forbidden (content, destination). The attempt is stored as a message with status blocked; its id is `message_id` in the error. |
| 409 | Idempotency conflict |
| 429 | Rate limit or quota exceeded |
| 502 | The carrier permanently rejected the message (carrier_error) |
| 503 | The content screen could not check the message (screen_unavailable), so it was not sent or charged; retry after Retry-After. Stored as a blocked message; its id is `message_id` in the error. |

Every error has the same JSON shape, and `request_id` matches the `X-Request-Id` response header. [Errors](/docs/errors) lists every code and what to do about it.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "What went wrong, in plain words.",
    "param": "the_field",
    "request_id": "req_a1B2c3D4e5F6g7H8"
  }
}
```

## Idempotency

Send an `Idempotency-Key` header to make retries safe. For 24 hours the same key with the same body replays the original successful response (with `Idempotent-Replayed: true`); the same key with a different body returns 409. A failed request releases its key.

## Examples

### Node.js

```javascript
import { TextKit } from 'jointextkit'; // npm install jointextkit

const sms = new TextKit(process.env.TEXTKIT_API_KEY);

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

console.log(message.id, message.status);
```

### PHP

```php
<?php

$ch = curl_init('https://api.jointextkit.com/v1/messages');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('TEXTKIT_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'to' => '+15005550006',
        'from' => '+15005550100',
        'body' => 'Hello from TextKit',
        'tags' => [
            'spring-sale',
        ],
        'status_callback' => 'https://example.com/sms/status',
    ]),
]);

$data = json_decode(curl_exec($ch), true);
```

### Python

```python
import os
from jointextkit import TextKit  # pip install jointextkit

client = TextKit(os.environ["TEXTKIT_API_KEY"])

message = client.messages.send(
    to="+15005550006",
    body="Hello from TextKit",
)

print(message["id"], message["status"])
```

### Ruby

```ruby
require "net/http"
require "json"

uri = URI("https://api.jointextkit.com/v1/messages")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV["TEXTKIT_API_KEY"]}"
req["Content-Type"] = "application/json"
req.body = {
  "to": "+15005550006",
  "from": "+15005550100",
  "body": "Hello from TextKit",
  "tags": [
    "spring-sale"
  ],
  "status_callback": "https://example.com/sms/status"
}.to_json

res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)
```

### Go

```go
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

func main() {
	body := strings.NewReader(`{
	  "to": "+15005550006",
	  "from": "+15005550100",
	  "body": "Hello from TextKit",
	  "tags": [
	    "spring-sale"
	  ],
	  "status_callback": "https://example.com/sms/status"
	}`)
	req, _ := http.NewRequest("POST", "https://api.jointextkit.com/v1/messages", body)
	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 {
		panic(err)
	}
	defer res.Body.Close()
	data, _ := io.ReadAll(res.Body)
	fmt.Println(res.Status, string(data))
}
```

### Rust

```rust
// cargo add reqwest --features blocking,json
// cargo add serde_json
use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let key = std::env::var("TEXTKIT_API_KEY")?;
    let res = reqwest::blocking::Client::new()
        .post("https://api.jointextkit.com/v1/messages")
        .bearer_auth(key)
        .json(&json!({
          "to": "+15005550006",
          "from": "+15005550100",
          "body": "Hello from TextKit",
          "tags": [
            "spring-sale"
          ],
          "status_callback": "https://example.com/sms/status"
        }))
        .send()?
        .error_for_status()?;
    let data: serde_json::Value = res.json()?;
    println!("{data}");
    Ok(())
}
```

### Java

```java
import java.net.URI;
import java.net.http.*;

public class Main {
    public static void main(String[] args) throws Exception {
        var request = HttpRequest.newBuilder(URI.create("https://api.jointextkit.com/v1/messages"))
            .header("Authorization", "Bearer " + System.getenv("TEXTKIT_API_KEY"))
            .header("Content-Type", "application/json")
            .method("POST", HttpRequest.BodyPublishers.ofString("""
                {
                  "to": "+15005550006",
                  "from": "+15005550100",
                  "body": "Hello from TextKit",
                  "tags": [
                    "spring-sale"
                  ],
                  "status_callback": "https://example.com/sms/status"
                }
                """))
            .build();

        var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode() + " " + response.body());
    }
}
```

### .NET

```csharp
using System.Net.Http.Headers;
using System.Text;

var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("TEXTKIT_API_KEY"));

var body = new StringContent("""
    {
      "to": "+15005550006",
      "from": "+15005550100",
      "body": "Hello from TextKit",
      "tags": [
        "spring-sale"
      ],
      "status_callback": "https://example.com/sms/status"
    }
    """, Encoding.UTF8, "application/json");
var res = await http.PostAsync("https://api.jointextkit.com/v1/messages", body);
res.EnsureSuccessStatusCode();

var data = await res.Content.ReadAsStringAsync();
```

### cURL

```bash
curl -X POST "https://api.jointextkit.com/v1/messages" \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2f0e-order-1042" \
  -d '{
  "to": "+15005550006",
  "from": "+15005550100",
  "body": "Hello from TextKit",
  "tags": [
    "spring-sale"
  ],
  "status_callback": "https://example.com/sms/status"
}'
```

## Related

- Guide: [Messages](/docs/messages)
- [All messages endpoints](/docs/api#messages)
- [API reference](/docs/api)
