# Send a batch

Source: https://jointextkit.com/docs/api/batches/create-batch
Index: https://jointextkit.com/llms.txt

`POST /v1/batches`

One body to up to 10,000 recipients, each with its own merge variables (or to contacts by tag), sent as individual messages through the normal pipeline. Numbers are normalized, deduplicated and checked against opt-outs before queueing; rejected rows are returned with reasons. Set dry_run to validate without sending.

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.

## Request body

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string | No | One of your numbers. Optional while the key's mode has one number. Example: `+15005550100`. |
| `body` | string | Yes | At most 1600 characters. Example: `Hi {{first_name}}, your order {{order_id}} has shipped.`. |
| `name` | string | No | At most 80 characters. |
| `recipients` | array of object | No | At most 10000 items. |
| `recipients[].to` | string | Yes | Example: `+14155550132`. |
| `recipients[].variables` | object | No |  |
| `tags` | array of string | No | Send to contacts carrying any of these tags (instead of recipients). |
| `contact_ids` | array of string | No |  |
| `segment_id` | string | No | Send to the contacts in this saved segment, evaluated when the batch is created (instead of recipients). |
| `list_id` | string | No | Send to the contacts in this list, as it is when the batch is created (instead of recipients). |
| `category` | `marketing`, `transactional`, `otp` | No | Optional. It can make the batch stricter (`marketing`), never looser: the sending number's registration and the wording decide ([which messages are marketing](/docs/compliance-checks#which-messages-are-marketing)). From a number with no registration, a batch is marketing. |
| `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: each recipient in quiet hours is held until their 8:00am. |
| `topic_id` | string | No | Subscription topic. Recipients unsubscribed from it are removed at validation (`topic_unsubscribed`) and skipped at send time, like opt-outs. Omit to apply opt-outs only. Example: `tp_marketing`. |
| `scheduled_at` | string | No | Future ISO timestamp or epoch ms, up to 30 days out. |
| `dry_run` | boolean | No | Validate only: returns a BatchValidation and sends nothing. |
| `check_line_types` | boolean | No | Reject landlines (first 500 unique numbers are checked). |

## Responses

| Status | Meaning | Body |
| --- | --- | --- |
| 200 | Dry run result | `BatchValidation` |
| 201 | Batch created | `Batch` |
| 400 | Invalid request or no valid recipients | `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 | `from` not owned, or key mode does not match the number | `Error` |
| 429 | Rate limit or quota exceeded | `Error` |

### 200: `BatchValidation` fields

| Field | Type | Description |
| --- | --- | --- |
| `object` | `batch_validation` |  |
| `counts` | object |  |
| `counts.rows` | integer |  |
| `counts.valid` | integer |  |
| `counts.invalid` | integer |  |
| `counts.duplicates` | integer |  |
| `counts.opted_out` | integer |  |
| `counts.topic_unsubscribed` | integer | Unsubscribed from the batch topic_id; 0 without a topic |
| `counts.missing_variable` | integer |  |
| `counts.landline` | integer |  |
| `counts.empty_after_merge` | integer |  |
| `unknown_variables` | array of string |  |
| `segments` | object |  |
| `segments.total` | integer |  |
| `segments.max` | integer |  |
| `segments.encoding` | `GSM-7`, `UCS-2` |  |
| `estimated_cost_usd` | number |  |
| `preview` | array of object |  |
| `preview[].to` | string |  |
| `preview[].body` | string |  |
| `preview[].segments` | integer |  |
| `rejected` | array of object |  |
| `rejected[].row` | integer | 0-based index into recipients |
| `rejected[].to` | string |  |
| `rejected[].reason` | `invalid`, `duplicate`, `opted_out`, `topic_unsubscribed`, `missing_variable`, `landline`, `empty_after_merge` |  |
| `rejected[].detail` | string |  |
| `consent` | object | Dry run only. What the consent gate would decide at send time for the first 1,000 accepted recipients: `no_opt_in` would be refused `consent_required` (marketing without an opt-in on file, or a recurring message from a registered number). Informational; every message is still checked when it sends. |
| `consent.checked` | integer |  |
| `consent.unchecked` | integer | Accepted recipients past the first 1,000, not checked |
| `consent.no_opt_in` | integer |  |
| `consent.no_opt_in_sample` | array of string | Up to 20 of them |
| `consent.unknown` | integer | Consent could not be read; the send retries these |

### 201: `Batch` fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Example: `bc_a1B2c3D4e5F6`. |
| `object` | `batch` |  |
| `name` | string |  |
| `status` | `scheduled`, `sending`, `paused`, `complete`, `canceled` |  |
| `from` | string |  |
| `body` | string |  |
| `source` | `api`, `csv`, `contacts` |  |
| `test` | boolean |  |
| `scheduled_at` | string (date-time) | Nullable. |
| `created_at` | string (date-time) |  |
| `completed_at` | string (date-time) | Nullable. |
| `counts` | object | total = queued + sent + failed + opted_out + canceled, always. "sent" = accepted by the carrier; what happened next (delivered, failed, replies) is GET /v1/batches/{id}/results. "opted_out" also counts recipients unsubscribed from the batch topic. |
| `counts.total` | integer |  |
| `counts.queued` | integer |  |
| `counts.sent` | integer |  |
| `counts.failed` | integer |  |
| `counts.opted_out` | integer |  |
| `counts.canceled` | integer |  |
| `rejected` | object | Rows dropped at validation (not part of total), by reason. Nullable. |
| `variables` | array of string |  |
| `retries` | integer |  |

## Errors

| Status | When |
| --- | --- |
| 400 | Invalid request or no valid recipients |
| 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 | `from` not owned, or key mode does not match the number |
| 429 | Rate limit or quota exceeded |

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

## Examples

### Node.js

The Node.js SDK does not wrap this endpoint yet; call it over HTTP.

```javascript
const res = await fetch('https://api.jointextkit.com/v1/batches', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TEXTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "from": "+15005550100",
    "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
    "topic_id": "tp_marketing"
  }),
});

if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```

### PHP

```php
<?php

$ch = curl_init('https://api.jointextkit.com/v1/batches');
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([
        'from' => '+15005550100',
        'body' => 'Hi {{first_name}}, your order {{order_id}} has shipped.',
        'topic_id' => 'tp_marketing',
    ]),
]);

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

### Python

The Python SDK does not wrap this endpoint yet; call it over HTTP.

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://api.jointextkit.com/v1/batches",
    method="POST",
    headers={
        "Authorization": f"Bearer {os.environ['TEXTKIT_API_KEY']}",
        "Content-Type": "application/json",
    },
    data=json.dumps({
        "from": "+15005550100",
        "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
        "topic_id": "tp_marketing"
    }).encode(),
)
with urllib.request.urlopen(req) as res:
    data = json.load(res)
```

### Ruby

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

uri = URI("https://api.jointextkit.com/v1/batches")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV["TEXTKIT_API_KEY"]}"
req["Content-Type"] = "application/json"
req.body = {
  "from": "+15005550100",
  "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
  "topic_id": "tp_marketing"
}.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(`{
	  "from": "+15005550100",
	  "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
	  "topic_id": "tp_marketing"
	}`)
	req, _ := http.NewRequest("POST", "https://api.jointextkit.com/v1/batches", 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/batches")
        .bearer_auth(key)
        .json(&json!({
          "from": "+15005550100",
          "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
          "topic_id": "tp_marketing"
        }))
        .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/batches"))
            .header("Authorization", "Bearer " + System.getenv("TEXTKIT_API_KEY"))
            .header("Content-Type", "application/json")
            .method("POST", HttpRequest.BodyPublishers.ofString("""
                {
                  "from": "+15005550100",
                  "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
                  "topic_id": "tp_marketing"
                }
                """))
            .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("""
    {
      "from": "+15005550100",
      "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
      "topic_id": "tp_marketing"
    }
    """, Encoding.UTF8, "application/json");
var res = await http.PostAsync("https://api.jointextkit.com/v1/batches", body);
res.EnsureSuccessStatusCode();

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

### cURL

```bash
curl -X POST "https://api.jointextkit.com/v1/batches" \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "from": "+15005550100",
  "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
  "topic_id": "tp_marketing"
}'
```

## Related

- Guide: [Batches & broadcasts](/docs/broadcasts)
- [All batches endpoints](/docs/api#batches)
- [API reference](/docs/api)
