Skip to content

Bulk & personalisation

Sending one message to many people is sms. Sending different messages needs a decision: build each one yourself with bulk, or store the variable parts on a contact list and let TextMe merge them.

Which approach?

bulksms + add_dynamic
Where the variable data livesYour databaseTextMe's contact list
Message bodyYou compose each oneOne template, merged server-side
Per-recipient senderYes — each message has its own sourceNo — one source for the send
Per-recipient short linksYesNo
Batch ceiling2,500 message objects per callWhatever the list holds
Best forTransactional: order numbers, one-time codes, delivery windowsMarketing: a template with a name and a city in it

The rule of thumb: if the variable value comes from a system of record you already own, use bulk. If it is a stable attribute of the contact — first name, branch, city — the contact list is less work and far less payload.

Approach 1 — bulk

One call, up to 2,500 message objects, each with its own text, sender and destinations.

xml
<?xml version="1.0" encoding="UTF-8"?>
<bulk>
    <user>
        <username>Leeroy</username>
    </user>
    <messages>
        <sms>
            <source>DemoAPI</source>
            <destinations>
                <phone id="external id1">5xxxxxxxx</phone>
                <phone id="external id2">5xxxxxxxx</phone>
                <phone>5xxxxxxxx</phone>
                <phone id="">5xxxxxxxx</phone>
            </destinations>
            <message>This is a sample message</message>
        </sms>
        <sms>
            <source>DemoAPI</source>
            <destinations>
                <phone id="">5xxxxxxxx</phone>
            </destinations>
            <message>This is a different message sent. [link-a1]</message>
            <links>
                <link id="a1">https://www.example.com/path/to/resource</link>
            </links>
        </sms>
    </messages>
    <timing>10/10/17 10:10</timing>
    <temp_bl>2</temp_bl>
    <includes_international>1</includes_international>
    <campaign_name>Sample Bulk Campaign</campaign_name>
</bulk>
json
{
  "bulk": {
    "user": {
      "username": "Leeroy"
    },
    "messages": {
      "sms": [
        {
          "source": "DemoAPI",
          "destinations": {
            "phone": [
              {
                "$": {
                  "id": "external id1"
                },
                "_": "5xxxxxxxx"
              },
              {
                "$": {
                  "id": "external id2"
                },
                "_": "5xxxxxxxx"
              },
              {
                "_": "5xxxxxxxx"
              },
              {
                "$": {
                  "id": ""
                },
                "_": "5xxxxxxxx"
              }
            ]
          },
          "message": "This is a sample message"
        },
        {
          "source": "DemoAPI",
          "destinations": {
            "phone": {
              "$": {
                "id": ""
              },
              "_": "5xxxxxxxx"
            }
          },
          "message": "This is a different message sent. [link-a1]",
          "links": {
            "link": [
              {
                "$": {
                  "id": "a1"
                },
                "_": "https://www.example.com/path/to/resource"
              }
            ]
          }
        }
      ]
    },
    "timing": "10/10/17 10:10",
    "temp_bl": "2",
    "includes_international": "1",
    "campaign_name": "Sample Bulk Campaign"
  }
}
bash
curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
  "bulk": {
    "user": {
      "username": "Leeroy"
    },
    "messages": {
      "sms": [
        {
          "source": "DemoAPI",
          "destinations": {
            "phone": [
              {
                "$": {
                  "id": "external id1"
                },
                "_": "5xxxxxxxx"
              },
              {
                "$": {
                  "id": "external id2"
                },
                "_": "5xxxxxxxx"
              },
              {
                "_": "5xxxxxxxx"
              },
              {
                "$": {
                  "id": ""
                },
                "_": "5xxxxxxxx"
              }
            ]
          },
          "message": "This is a sample message"
        },
        {
          "source": "DemoAPI",
          "destinations": {
            "phone": {
              "$": {
                "id": ""
              },
              "_": "5xxxxxxxx"
            }
          },
          "message": "This is a different message sent. [link-a1]",
          "links": {
            "link": [
              {
                "$": {
                  "id": "a1"
                },
                "_": "https://www.example.com/path/to/resource"
              }
            ]
          }
        }
      ]
    },
    "timing": "10/10/17 10:10",
    "temp_bl": "2",
    "includes_international": "1",
    "campaign_name": "Sample Bulk Campaign"
  }
}'
js
// Node.js 18+ / browsers — no dependencies
const response = await fetch('https://my.textme.co.il/api', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TEXTME_API_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    bulk: {
      user: {
        username: 'Leeroy',
      },
      messages: {
        sms: [
          {
            source: 'DemoAPI',
            destinations: {
              phone: [
                {
                  $: {
                    id: 'external id1',
                  },
                  _: '5xxxxxxxx',
                },
                {
                  $: {
                    id: 'external id2',
                  },
                  _: '5xxxxxxxx',
                },
                {
                  _: '5xxxxxxxx',
                },
                {
                  $: {
                    id: '',
                  },
                  _: '5xxxxxxxx',
                },
              ],
            },
            message: 'This is a sample message',
          },
          {
            source: 'DemoAPI',
            destinations: {
              phone: {
                $: {
                  id: '',
                },
                _: '5xxxxxxxx',
              },
            },
            message: 'This is a different message sent. [link-a1]',
            links: {
              link: [
                {
                  $: {
                    id: 'a1',
                  },
                  _: 'https://www.example.com/path/to/resource',
                },
              ],
            },
          },
        ],
      },
      timing: '10/10/17 10:10',
      temp_bl: '2',
      includes_international: '1',
      campaign_name: 'Sample Bulk Campaign',
    },
  }),
})

const result = await response.json()

// Errors arrive as HTTP 200 too — the payload status is what counts
if (Number(result.status) !== 0) {
  throw new Error(`TextMe ${result.status}: ${result.message}`)
}

console.log(result)
php
<?php
// composer require guzzlehttp/guzzle

$client = new \GuzzleHttp\Client([
    'headers' => [
        'Authorization' => 'Bearer '.getenv('TEXTME_API_TOKEN'),
        'Accept' => 'application/json',
    ],
]);

$response = $client->post('https://my.textme.co.il/api', [
    'json' => [
        'bulk' => [
            'user' => [
                'username' => 'Leeroy',
            ],
            'messages' => [
                'sms' => [
                    [
                        'source' => 'DemoAPI',
                        'destinations' => [
                            'phone' => [
                                [
                                    '$' => [
                                        'id' => 'external id1',
                                    ],
                                    '_' => '5xxxxxxxx',
                                ],
                                [
                                    '$' => [
                                        'id' => 'external id2',
                                    ],
                                    '_' => '5xxxxxxxx',
                                ],
                                [
                                    '_' => '5xxxxxxxx',
                                ],
                                [
                                    '$' => [
                                        'id' => '',
                                    ],
                                    '_' => '5xxxxxxxx',
                                ],
                            ],
                        ],
                        'message' => 'This is a sample message',
                    ],
                    [
                        'source' => 'DemoAPI',
                        'destinations' => [
                            'phone' => [
                                '$' => [
                                    'id' => '',
                                ],
                                '_' => '5xxxxxxxx',
                            ],
                        ],
                        'message' => 'This is a different message sent. [link-a1]',
                        'links' => [
                            'link' => [
                                [
                                    '$' => [
                                        'id' => 'a1',
                                    ],
                                    '_' => 'https://www.example.com/path/to/resource',
                                ],
                            ],
                        ],
                    ],
                ],
            ],
            'timing' => '10/10/17 10:10',
            'temp_bl' => '2',
            'includes_international' => '1',
            'campaign_name' => 'Sample Bulk Campaign',
        ],
    ],
]);

$result = json_decode($response->getBody()->getContents(), true);

// Errors arrive as HTTP 200 too — the payload status is what counts
if ((int) $result['status'] !== 0) {
    throw new RuntimeException("TextMe {$result['status']}: {$result['message']}");
}

print_r($result);
php
<?php

use Illuminate\Support\Facades\Http;

$result = Http::withToken(config('services.textme.token'))
    ->acceptJson()
    ->post('https://my.textme.co.il/api', [
        'bulk' => [
            'user' => [
                'username' => 'Leeroy',
            ],
            'messages' => [
                'sms' => [
                    [
                        'source' => 'DemoAPI',
                        'destinations' => [
                            'phone' => [
                                [
                                    '$' => [
                                        'id' => 'external id1',
                                    ],
                                    '_' => '5xxxxxxxx',
                                ],
                                [
                                    '$' => [
                                        'id' => 'external id2',
                                    ],
                                    '_' => '5xxxxxxxx',
                                ],
                                [
                                    '_' => '5xxxxxxxx',
                                ],
                                [
                                    '$' => [
                                        'id' => '',
                                    ],
                                    '_' => '5xxxxxxxx',
                                ],
                            ],
                        ],
                        'message' => 'This is a sample message',
                    ],
                    [
                        'source' => 'DemoAPI',
                        'destinations' => [
                            'phone' => [
                                '$' => [
                                    'id' => '',
                                ],
                                '_' => '5xxxxxxxx',
                            ],
                        ],
                        'message' => 'This is a different message sent. [link-a1]',
                        'links' => [
                            'link' => [
                                [
                                    '$' => [
                                        'id' => 'a1',
                                    ],
                                    '_' => 'https://www.example.com/path/to/resource',
                                ],
                            ],
                        ],
                    ],
                ],
            ],
            'timing' => '10/10/17 10:10',
            'temp_bl' => '2',
            'includes_international' => '1',
            'campaign_name' => 'Sample Bulk Campaign',
        ],
    ])
    ->throw()
    ->json();

// Errors arrive as HTTP 200 too — the payload status is what counts
throw_if((int) $result['status'] !== 0, RuntimeException::class,
    "TextMe {$result['status']}: {$result['message']}");

logger()->info('TextMe', $result);
python
# pip install httpx
import os

import httpx

response = httpx.post(
    "https://my.textme.co.il/api",
    headers={"Authorization": f"Bearer {os.environ['TEXTME_API_TOKEN']}"},
    json={
        "bulk": {
            "user": {
                "username": "Leeroy",
            },
            "messages": {
                "sms": [
                    {
                        "source": "DemoAPI",
                        "destinations": {
                            "phone": [
                                {
                                    "$": {
                                        "id": "external id1",
                                    },
                                    "_": "5xxxxxxxx",
                                },
                                {
                                    "$": {
                                        "id": "external id2",
                                    },
                                    "_": "5xxxxxxxx",
                                },
                                {
                                    "_": "5xxxxxxxx",
                                },
                                {
                                    "$": {
                                        "id": "",
                                    },
                                    "_": "5xxxxxxxx",
                                },
                            ],
                        },
                        "message": "This is a sample message",
                    },
                    {
                        "source": "DemoAPI",
                        "destinations": {
                            "phone": {
                                "$": {
                                    "id": "",
                                },
                                "_": "5xxxxxxxx",
                            },
                        },
                        "message": "This is a different message sent. [link-a1]",
                        "links": {
                            "link": [
                                {
                                    "$": {
                                        "id": "a1",
                                    },
                                    "_": "https://www.example.com/path/to/resource",
                                },
                            ],
                        },
                    },
                ],
            },
            "timing": "10/10/17 10:10",
            "temp_bl": "2",
            "includes_international": "1",
            "campaign_name": "Sample Bulk Campaign",
        },
    },
)
response.raise_for_status()
result = response.json()

# Errors arrive as HTTP 200 too — the payload status is what counts
if int(result["status"]) != 0:
    raise RuntimeError(f"TextMe {result['status']}: {result['message']}")

print(result)
go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	payload, _ := json.Marshal(map[string]any{
		"bulk": map[string]any{
			"user": map[string]any{
				"username": "Leeroy",
			},
			"messages": map[string]any{
				"sms": []any{
					map[string]any{
						"source": "DemoAPI",
						"destinations": map[string]any{
							"phone": []any{
								map[string]any{
									"$": map[string]any{
										"id": "external id1",
									},
									"_": "5xxxxxxxx",
								},
								map[string]any{
									"$": map[string]any{
										"id": "external id2",
									},
									"_": "5xxxxxxxx",
								},
								map[string]any{
									"_": "5xxxxxxxx",
								},
								map[string]any{
									"$": map[string]any{
										"id": "",
									},
									"_": "5xxxxxxxx",
								},
							},
						},
						"message": "This is a sample message",
					},
					map[string]any{
						"source": "DemoAPI",
						"destinations": map[string]any{
							"phone": map[string]any{
								"$": map[string]any{
									"id": "",
								},
								"_": "5xxxxxxxx",
							},
						},
						"message": "This is a different message sent. [link-a1]",
						"links": map[string]any{
							"link": []any{
								map[string]any{
									"$": map[string]any{
										"id": "a1",
									},
									"_": "https://www.example.com/path/to/resource",
								},
							},
						},
					},
				},
			},
			"timing": "10/10/17 10:10",
			"temp_bl": "2",
			"includes_international": "1",
			"campaign_name": "Sample Bulk Campaign",
		},
	})

	req, _ := http.NewRequest("POST", "https://my.textme.co.il/api", bytes.NewReader(payload))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("TEXTME_API_TOKEN"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var result struct {
		Status  json.Number `json:"status"`
		Message string      `json:"message"`
	}
	if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
		panic(err)
	}

	// Errors arrive as HTTP 200 too — the payload status is what counts
	if result.Status.String() != "0" {
		panic(fmt.Sprintf("TextMe %s: %s", result.Status, result.Message))
	}

	fmt.Println(result.Message)
}
java
// Java 17+ — java.net.http, no dependencies (parse with Jackson/Gson)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class TextMeSendBulk {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "bulk": {
                "user": {
                  "username": "Leeroy"
                },
                "messages": {
                  "sms": [
                    {
                      "source": "DemoAPI",
                      "destinations": {
                        "phone": [
                          {
                            "$": {
                              "id": "external id1"
                            },
                            "_": "5xxxxxxxx"
                          },
                          {
                            "$": {
                              "id": "external id2"
                            },
                            "_": "5xxxxxxxx"
                          },
                          {
                            "_": "5xxxxxxxx"
                          },
                          {
                            "$": {
                              "id": ""
                            },
                            "_": "5xxxxxxxx"
                          }
                        ]
                      },
                      "message": "This is a sample message"
                    },
                    {
                      "source": "DemoAPI",
                      "destinations": {
                        "phone": {
                          "$": {
                            "id": ""
                          },
                          "_": "5xxxxxxxx"
                        }
                      },
                      "message": "This is a different message sent. [link-a1]",
                      "links": {
                        "link": [
                          {
                            "$": {
                              "id": "a1"
                            },
                            "_": "https://www.example.com/path/to/resource"
                          }
                        ]
                      }
                    }
                  ]
                },
                "timing": "10/10/17 10:10",
                "temp_bl": "2",
                "includes_international": "1",
                "campaign_name": "Sample Bulk Campaign"
              }
            }
            """;

        HttpRequest request = HttpRequest.newBuilder(URI.create("https://my.textme.co.il/api"))
            .header("Authorization", "Bearer " + System.getenv("TEXTME_API_TOKEN"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        // Errors arrive as HTTP 200 too — the payload status is what counts
        System.out.println(response.body());
    }
}
csharp
// .NET 8+ — System.Net.Http
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

var payload = """
    {
      "bulk": {
        "user": {
          "username": "Leeroy"
        },
        "messages": {
          "sms": [
            {
              "source": "DemoAPI",
              "destinations": {
                "phone": [
                  {
                    "$": {
                      "id": "external id1"
                    },
                    "_": "5xxxxxxxx"
                  },
                  {
                    "$": {
                      "id": "external id2"
                    },
                    "_": "5xxxxxxxx"
                  },
                  {
                    "_": "5xxxxxxxx"
                  },
                  {
                    "$": {
                      "id": ""
                    },
                    "_": "5xxxxxxxx"
                  }
                ]
              },
              "message": "This is a sample message"
            },
            {
              "source": "DemoAPI",
              "destinations": {
                "phone": {
                  "$": {
                    "id": ""
                  },
                  "_": "5xxxxxxxx"
                }
              },
              "message": "This is a different message sent. [link-a1]",
              "links": {
                "link": [
                  {
                    "$": {
                      "id": "a1"
                    },
                    "_": "https://www.example.com/path/to/resource"
                  }
                ]
              }
            }
          ]
        },
        "timing": "10/10/17 10:10",
        "temp_bl": "2",
        "includes_international": "1",
        "campaign_name": "Sample Bulk Campaign"
      }
    }
    """;

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

var response = await http.PostAsync("https://my.textme.co.il/api",
    new StringContent(payload, Encoding.UTF8, "application/json"));

var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
var status = result.GetProperty("status").ToString();

// Errors arrive as HTTP 200 too — the payload status is what counts
if (status != "0")
{
    var message = result.GetProperty("message").ToString();
    throw new Exception($"TextMe {status}: {message}");
}

Console.WriteLine(result);
ruby
require "net/http"
require "json"

uri = URI("https://my.textme.co.il/api")

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('TEXTME_API_TOKEN')}"
request["Content-Type"] = "application/json"
request.body = JSON.dump({
  "bulk" => {
    "user" => {
      "username" => "Leeroy",
    },
    "messages" => {
      "sms" => [
        {
          "source" => "DemoAPI",
          "destinations" => {
            "phone" => [
              {
                "$" => {
                  "id" => "external id1",
                },
                "_" => "5xxxxxxxx",
              },
              {
                "$" => {
                  "id" => "external id2",
                },
                "_" => "5xxxxxxxx",
              },
              {
                "_" => "5xxxxxxxx",
              },
              {
                "$" => {
                  "id" => "",
                },
                "_" => "5xxxxxxxx",
              },
            ],
          },
          "message" => "This is a sample message",
        },
        {
          "source" => "DemoAPI",
          "destinations" => {
            "phone" => {
              "$" => {
                "id" => "",
              },
              "_" => "5xxxxxxxx",
            },
          },
          "message" => "This is a different message sent. [link-a1]",
          "links" => {
            "link" => [
              {
                "$" => {
                  "id" => "a1",
                },
                "_" => "https://www.example.com/path/to/resource",
              },
            ],
          },
        },
      ],
    },
    "timing" => "10/10/17 10:10",
    "temp_bl" => "2",
    "includes_international" => "1",
    "campaign_name" => "Sample Bulk Campaign",
  },
})

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

result = JSON.parse(response.body)

# Errors arrive as HTTP 200 too — the payload status is what counts
raise "TextMe #{result['status']}: #{result['message']}" unless result["status"].to_i.zero?

pp result
rust
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result: Value = reqwest::Client::new()
        .post("https://my.textme.co.il/api")
        .bearer_auth(std::env::var("TEXTME_API_TOKEN")?)
        .json(&json!({
          "bulk": {
            "user": {
              "username": "Leeroy"
            },
            "messages": {
              "sms": [
                {
                  "source": "DemoAPI",
                  "destinations": {
                    "phone": [
                      {
                        "$": {
                          "id": "external id1"
                        },
                        "_": "5xxxxxxxx"
                      },
                      {
                        "$": {
                          "id": "external id2"
                        },
                        "_": "5xxxxxxxx"
                      },
                      {
                        "_": "5xxxxxxxx"
                      },
                      {
                        "$": {
                          "id": ""
                        },
                        "_": "5xxxxxxxx"
                      }
                    ]
                  },
                  "message": "This is a sample message"
                },
                {
                  "source": "DemoAPI",
                  "destinations": {
                    "phone": {
                      "$": {
                        "id": ""
                      },
                      "_": "5xxxxxxxx"
                    }
                  },
                  "message": "This is a different message sent. [link-a1]",
                  "links": {
                    "link": [
                      {
                        "$": {
                          "id": "a1"
                        },
                        "_": "https://www.example.com/path/to/resource"
                      }
                    ]
                  }
                }
              ]
            },
            "timing": "10/10/17 10:10",
            "temp_bl": "2",
            "includes_international": "1",
            "campaign_name": "Sample Bulk Campaign"
          }
        }))
        .send()
        .await?
        .json()
        .await?;

    // Errors arrive as HTTP 200 too — the payload status is what counts
    if result["status"] != 0 {
        return Err(format!("TextMe {}: {}", result["status"], result["message"]).into());
    }

    println!("{result}");
    Ok(())
}
xml
<?xml version="1.0" encoding="UTF-8"?>
<sms>
    <status>0</status>
    <message>SMS bulk will be sent</message>
    <shipment_id>xxxxxxx</shipment_id>
</sms>
json
{
  "status": 0,
  "message": "SMS bulk will be sent",
  "shipment_id": "XXXXXXX"
}

Building the batch

The mechanical part is turning your own rows into message objects. The batch-level settings — scheduling, recency filtering, campaign name — sit outside messages:

js
const orders = await db.ordersReadyToShip()

const payload = {
  bulk: {
    user: { username: 'Leeroy' },
    messages: {
      sms: orders.map((order) => ({
        source: 'DemoAPI',
        destinations: {
          // The external id is what lets you reconcile this exact message later.
          phone: { $: { id: `order-${order.id}` }, _: order.phone },
        },
        message: `Order ${order.reference} ships today. Track it: [link-t]`,
        links: {
          link: [{ $: { id: 't' }, _: order.trackingUrl }],
        },
      })),
    },
    // Batch-level, outside `messages`.
    campaign_name: `shipping-${new Date().toISOString().slice(0, 10)}`,
    temp_bl: '1',
  },
}

const result = await textme(payload)
python
from datetime import date

orders = db.orders_ready_to_ship()

payload = {
    "bulk": {
        "user": {"username": "Leeroy"},
        "messages": {
            "sms": [
                {
                    "source": "DemoAPI",
                    "destinations": {
                        # The external id is what lets you reconcile this message.
                        "phone": {"$": {"id": f"order-{order.id}"}, "_": order.phone},
                    },
                    "message": f"Order {order.reference} ships today. Track it: [link-t]",
                    "links": {"link": [{"$": {"id": "t"}, "_": order.tracking_url}]},
                }
                for order in orders
            ]
        },
        # Batch-level, outside `messages`.
        "campaign_name": f"shipping-{date.today().isoformat()}",
        "temp_bl": "1",
    }
}

result = textme(payload)
php
<?php

$orders = $db->ordersReadyToShip();

$payload = [
    'bulk' => [
        'user' => ['username' => 'Leeroy'],
        'messages' => [
            'sms' => array_map(fn ($order) => [
                'source' => 'DemoAPI',
                'destinations' => [
                    // The external id lets you reconcile this exact message later.
                    'phone' => ['$' => ['id' => "order-{$order->id}"], '_' => $order->phone],
                ],
                'message' => "Order {$order->reference} ships today. Track it: [link-t]",
                'links' => [
                    'link' => [['$' => ['id' => 't'], '_' => $order->trackingUrl]],
                ],
            ], $orders),
        ],
        // Batch-level, outside `messages`.
        'campaign_name' => 'shipping-'.date('Y-m-d'),
        'temp_bl' => '1',
    ],
];

$result = textme($payload);

Chunking past 2,500

The ceiling counts message objects, not recipients. Split a larger run into several calls and give them all the same campaign_name — then one cancel by name stops the whole run if you need it to.

js
const BATCH = 2000 // under the 2,500 ceiling, with room to spare
const campaign = `shipping-${new Date().toISOString().slice(0, 10)}`
const shipments = []

for (let i = 0; i < orders.length; i += BATCH) {
  const result = await textme({
    bulk: {
      user: { username: 'Leeroy' },
      messages: { sms: orders.slice(i, i + BATCH).map(toMessage) },
      campaign_name: campaign, // shared, so one cancel stops every chunk
    },
  })

  shipments.push(result.shipment_id)
}
python
BATCH = 2000  # under the 2,500 ceiling, with room to spare
campaign = f"shipping-{date.today().isoformat()}"
shipments = []

for start in range(0, len(orders), BATCH):
    result = textme({
        "bulk": {
            "user": {"username": "Leeroy"},
            "messages": {"sms": [to_message(o) for o in orders[start:start + BATCH]]},
            "campaign_name": campaign,  # shared, so one cancel stops every chunk
        }
    })

    shipments.append(result["shipment_id"])

Check the balance first

A batch that outruns your credit fails as a whole with status 4, not partway through. balance before a large run turns that into a clean pre-flight failure.

Approach 2 — dynamic fields

When the variable part is an attribute of the contact rather than of an event, store it once and template the message.

Each contact on a list carries up to six values, df1df6, referenced in the body as [DYNAMIC_FIELD1][DYNAMIC_FIELD6]:

xml
<destination>
    <phone>055XXXXXXX</phone>
    <df1>Israel</df1>
    <df2>Israeli</df2>
    <df3>Haifa</df3>
</destination>
json
{
  "destination": {
    "phone": "055XXXXXXX",
    "df1": "Israel",
    "df2": "Israeli",
    "df3": "Haifa"
  }
}

Then send one templated message to the list:

xml
<?xml version="1.0" encoding="UTF-8"?>
<sms>
    <user>
        <username>Leeroy</username>
    </user>
    <source>DemoAPI</source>
    <destinations>
        <cl_id>21518</cl_id>
    </destinations>
    <message>Hello [DYNAMIC_FIELD1], your order is ready for collection in [DYNAMIC_FIELD3].</message>
    <add_dynamic>1</add_dynamic>
    <campaign_name>collection-reminder</campaign_name>
</sms>
json
{
  "sms": {
    "user": { "username": "Leeroy" },
    "source": "DemoAPI",
    "destinations": { "cl_id": "21518" },
    "message": "Hello [DYNAMIC_FIELD1], your order is ready for collection in [DYNAMIC_FIELD3].",
    "add_dynamic": "1",
    "campaign_name": "collection-reminder"
  }
}

add_dynamic has strict requirements

It works only with exactly one cl_id and no individual phone elements. The merge values have to come from a single list, so a send that mixes a list with loose numbers cannot personalise.

Full detail on building and maintaining lists is in Work with contact lists.

Reconciling a batch

Whichever approach you take, a batch returns one shipment_id for the whole thing. Per-recipient outcomes come from the external ids you set:

xml
<?xml version="1.0" encoding="UTF-8"?>
<dlr>
    <user>
        <username>Leeroy</username>
    </user>
    <transactions>
        <external_id>some id 1</external_id>
        <external_id>some id 2</external_id>
    </transactions>
    <from>01/01/14 00:00</from>
    <to>01/01/14 23:59</to>
</dlr>
json
{
  "dlr": {
    "user": {
      "username": "Leeroy"
    },
    "transactions": {
      "external_id": [
        "some id 1",
        "some id 2"
      ]
    },
    "from": "01/01/14 00:00",
    "to": "01/01/14 23:59"
  }
}
bash
curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
  "dlr": {
    "user": {
      "username": "Leeroy"
    },
    "transactions": {
      "external_id": [
        "some id 1",
        "some id 2"
      ]
    },
    "from": "01/01/14 00:00",
    "to": "01/01/14 23:59"
  }
}'
js
// Node.js 18+ / browsers — no dependencies
const response = await fetch('https://my.textme.co.il/api', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TEXTME_API_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    dlr: {
      user: {
        username: 'Leeroy',
      },
      transactions: {
        external_id: [
          'some id 1',
          'some id 2',
        ],
      },
      from: '01/01/14 00:00',
      to: '01/01/14 23:59',
    },
  }),
})

const result = await response.json()

// Errors arrive as HTTP 200 too — the payload status is what counts
if (Number(result.status) !== 0) {
  throw new Error(`TextMe ${result.status}: ${result.message}`)
}

console.log(result)
php
<?php
// composer require guzzlehttp/guzzle

$client = new \GuzzleHttp\Client([
    'headers' => [
        'Authorization' => 'Bearer '.getenv('TEXTME_API_TOKEN'),
        'Accept' => 'application/json',
    ],
]);

$response = $client->post('https://my.textme.co.il/api', [
    'json' => [
        'dlr' => [
            'user' => [
                'username' => 'Leeroy',
            ],
            'transactions' => [
                'external_id' => [
                    'some id 1',
                    'some id 2',
                ],
            ],
            'from' => '01/01/14 00:00',
            'to' => '01/01/14 23:59',
        ],
    ],
]);

$result = json_decode($response->getBody()->getContents(), true);

// Errors arrive as HTTP 200 too — the payload status is what counts
if ((int) $result['status'] !== 0) {
    throw new RuntimeException("TextMe {$result['status']}: {$result['message']}");
}

print_r($result);
php
<?php

use Illuminate\Support\Facades\Http;

$result = Http::withToken(config('services.textme.token'))
    ->acceptJson()
    ->post('https://my.textme.co.il/api', [
        'dlr' => [
            'user' => [
                'username' => 'Leeroy',
            ],
            'transactions' => [
                'external_id' => [
                    'some id 1',
                    'some id 2',
                ],
            ],
            'from' => '01/01/14 00:00',
            'to' => '01/01/14 23:59',
        ],
    ])
    ->throw()
    ->json();

// Errors arrive as HTTP 200 too — the payload status is what counts
throw_if((int) $result['status'] !== 0, RuntimeException::class,
    "TextMe {$result['status']}: {$result['message']}");

logger()->info('TextMe', $result);
python
# pip install httpx
import os

import httpx

response = httpx.post(
    "https://my.textme.co.il/api",
    headers={"Authorization": f"Bearer {os.environ['TEXTME_API_TOKEN']}"},
    json={
        "dlr": {
            "user": {
                "username": "Leeroy",
            },
            "transactions": {
                "external_id": [
                    "some id 1",
                    "some id 2",
                ],
            },
            "from": "01/01/14 00:00",
            "to": "01/01/14 23:59",
        },
    },
)
response.raise_for_status()
result = response.json()

# Errors arrive as HTTP 200 too — the payload status is what counts
if int(result["status"]) != 0:
    raise RuntimeError(f"TextMe {result['status']}: {result['message']}")

print(result)
go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	payload, _ := json.Marshal(map[string]any{
		"dlr": map[string]any{
			"user": map[string]any{
				"username": "Leeroy",
			},
			"transactions": map[string]any{
				"external_id": []any{
					"some id 1",
					"some id 2",
				},
			},
			"from": "01/01/14 00:00",
			"to": "01/01/14 23:59",
		},
	})

	req, _ := http.NewRequest("POST", "https://my.textme.co.il/api", bytes.NewReader(payload))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("TEXTME_API_TOKEN"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var result struct {
		Status  json.Number `json:"status"`
		Message string      `json:"message"`
	}
	if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
		panic(err)
	}

	// Errors arrive as HTTP 200 too — the payload status is what counts
	if result.Status.String() != "0" {
		panic(fmt.Sprintf("TextMe %s: %s", result.Status, result.Message))
	}

	fmt.Println(result.Message)
}
java
// Java 17+ — java.net.http, no dependencies (parse with Jackson/Gson)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class TextMeDlr {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "dlr": {
                "user": {
                  "username": "Leeroy"
                },
                "transactions": {
                  "external_id": [
                    "some id 1",
                    "some id 2"
                  ]
                },
                "from": "01/01/14 00:00",
                "to": "01/01/14 23:59"
              }
            }
            """;

        HttpRequest request = HttpRequest.newBuilder(URI.create("https://my.textme.co.il/api"))
            .header("Authorization", "Bearer " + System.getenv("TEXTME_API_TOKEN"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        // Errors arrive as HTTP 200 too — the payload status is what counts
        System.out.println(response.body());
    }
}
csharp
// .NET 8+ — System.Net.Http
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

var payload = """
    {
      "dlr": {
        "user": {
          "username": "Leeroy"
        },
        "transactions": {
          "external_id": [
            "some id 1",
            "some id 2"
          ]
        },
        "from": "01/01/14 00:00",
        "to": "01/01/14 23:59"
      }
    }
    """;

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

var response = await http.PostAsync("https://my.textme.co.il/api",
    new StringContent(payload, Encoding.UTF8, "application/json"));

var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
var status = result.GetProperty("status").ToString();

// Errors arrive as HTTP 200 too — the payload status is what counts
if (status != "0")
{
    var message = result.GetProperty("message").ToString();
    throw new Exception($"TextMe {status}: {message}");
}

Console.WriteLine(result);
ruby
require "net/http"
require "json"

uri = URI("https://my.textme.co.il/api")

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('TEXTME_API_TOKEN')}"
request["Content-Type"] = "application/json"
request.body = JSON.dump({
  "dlr" => {
    "user" => {
      "username" => "Leeroy",
    },
    "transactions" => {
      "external_id" => [
        "some id 1",
        "some id 2",
      ],
    },
    "from" => "01/01/14 00:00",
    "to" => "01/01/14 23:59",
  },
})

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

result = JSON.parse(response.body)

# Errors arrive as HTTP 200 too — the payload status is what counts
raise "TextMe #{result['status']}: #{result['message']}" unless result["status"].to_i.zero?

pp result
rust
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result: Value = reqwest::Client::new()
        .post("https://my.textme.co.il/api")
        .bearer_auth(std::env::var("TEXTME_API_TOKEN")?)
        .json(&json!({
          "dlr": {
            "user": {
              "username": "Leeroy"
            },
            "transactions": {
              "external_id": [
                "some id 1",
                "some id 2"
              ]
            },
            "from": "01/01/14 00:00",
            "to": "01/01/14 23:59"
          }
        }))
        .send()
        .await?
        .json()
        .await?;

    // Errors arrive as HTTP 200 too — the payload status is what counts
    if result["status"] != 0 {
        return Err(format!("TextMe {}: {}", result["status"], result["message"]).into());
    }

    println!("{result}");
    Ok(())
}

With ids shaped like order-10052, mapping a report row back to your own record is a string split. Without them, a batch of 2,000 messages produces 2,000 report rows you can only match on phone number — workable, but ambiguous the moment one person appears twice.

One id per destination, always

It costs nothing at send time and it is the only thing that makes a large run auditable afterwards. Make it a habit rather than a decision.

Suppressing recent recipients

temp_bl skips destinations that already heard from you in the last n days, 1 to 14. It is applied per destination at send time, so one value covers an audience assembled from several sources:

json
{ "temp_bl": "3" }

If it filters everyone out the call fails with status 715 rather than quietly sending nothing — which is the behaviour you want, because "nothing sent" and "everything suppressed" need different responses.

Next