Track delivery (DLR)
A send returns status: 0 when TextMe accepts the message. Whether a handset actually received it is a separate, asynchronous question — answered by a delivery report, or DLR.
This page covers the whole loop: tagging messages so they can be found, pulling reports, interpreting the statuses, and moving from polling to push.
Step 1 — Tag every destination
The id attribute on <phone> is the join key between TextMe's reports and your own records. Choose something meaningful from your domain:
<destinations>
<phone id="order-10052">5xxxxxxxx</phone>
<phone id="order-10053">5xxxxxxxx</phone>
</destinations>{
"destinations": {
"phone": [
{ "$": { "id": "order-10052" }, "_": "5xxxxxxxx" },
{ "$": { "id": "order-10053" }, "_": "5xxxxxxxx" }
]
}
}Messages sent without an id are still delivered — you simply cannot ask about them individually afterwards. Since the id costs nothing, set one on every destination you might ever need to explain.
Make ids unique and parseable
order-10052 beats 10052: it tells you which of your tables to look in when the report comes back a week later. Reusing an id across two sends makes the report ambiguous, so include something that varies — an attempt number, a timestamp — if the same order can be messaged twice.
Step 2 — Pull the reports
Two operations, depending on what you are asking.
By external id — dlr
"What happened to these specific messages?"
<?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>{
"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"
}
}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"
}
}'// 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
// 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
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);# 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)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 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());
}
}// .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);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// [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(())
}<?xml version="1.0" encoding="UTF-8"?>
<dlr>
<status>0</status>
<message></message>
<transactions>
<transaction>
<external_id>1391438285</external_id>
<status>102</status>
<he_message>הגיע ליעד</he_message>
<en_message>Delivered</en_message>
<date>03/02/14 16:38</date>
<shipment_id>12345678</shipment_id>
</transaction>
</transactions>
</dlr>{
"status": 0,
"message": "all is well!",
"transactions": [
{
"external_id": "1391438285",
"source": "Test",
"phone": "5XXXXXXXX",
"status": "102",
"message_he": "הגיע ליעד",
"en_message": "Delivered",
"shipment_id": "XXXXXXX",
"date": "02/05/23 10:22",
"operator": "Telzar"
}
]
}Two limits
- The window between
fromandtomay not exceed one week. - At most 1,000 ids per request — chunk larger sets.
By date — dlrByDate
"How did yesterday go?" No ids needed; send the literal string null in external_id.
<?xml version="1.0" encoding="UTF-8"?>
<dlrByDate>
<user>
<username>Leeroy</username>
</user>
<transactions>
<external_id>null</external_id>
</transactions>
<from>04/09/22 00:00</from>
<to>04/09/22 23:59</to>
</dlrByDate>{
"dlrByDate": {
"user": {
"username": "Leeroy"
},
"transactions": {
"external_id": "null"
},
"from": "04/09/22 00:00",
"to": "04/09/22 23:59"
}
}curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
"dlrByDate": {
"user": {
"username": "Leeroy"
},
"transactions": {
"external_id": "null"
},
"from": "04/09/22 00:00",
"to": "04/09/22 23:59"
}
}'// 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({
dlrByDate: {
user: {
username: 'Leeroy',
},
transactions: {
external_id: 'null',
},
from: '04/09/22 00:00',
to: '04/09/22 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
// 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' => [
'dlrByDate' => [
'user' => [
'username' => 'Leeroy',
],
'transactions' => [
'external_id' => 'null',
],
'from' => '04/09/22 00:00',
'to' => '04/09/22 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
use Illuminate\Support\Facades\Http;
$result = Http::withToken(config('services.textme.token'))
->acceptJson()
->post('https://my.textme.co.il/api', [
'dlrByDate' => [
'user' => [
'username' => 'Leeroy',
],
'transactions' => [
'external_id' => 'null',
],
'from' => '04/09/22 00:00',
'to' => '04/09/22 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);# 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={
"dlrByDate": {
"user": {
"username": "Leeroy",
},
"transactions": {
"external_id": "null",
},
"from": "04/09/22 00:00",
"to": "04/09/22 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)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]any{
"dlrByDate": map[string]any{
"user": map[string]any{
"username": "Leeroy",
},
"transactions": map[string]any{
"external_id": "null",
},
"from": "04/09/22 00:00",
"to": "04/09/22 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 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 TextMeDlrByDate {
public static void main(String[] args) throws Exception {
String body = """
{
"dlrByDate": {
"user": {
"username": "Leeroy"
},
"transactions": {
"external_id": "null"
},
"from": "04/09/22 00:00",
"to": "04/09/22 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());
}
}// .NET 8+ — System.Net.Http
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var payload = """
{
"dlrByDate": {
"user": {
"username": "Leeroy"
},
"transactions": {
"external_id": "null"
},
"from": "04/09/22 00:00",
"to": "04/09/22 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);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({
"dlrByDate" => {
"user" => {
"username" => "Leeroy",
},
"transactions" => {
"external_id" => "null",
},
"from" => "04/09/22 00:00",
"to" => "04/09/22 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// [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!({
"dlrByDate": {
"user": {
"username": "Leeroy"
},
"transactions": {
"external_id": "null"
},
"from": "04/09/22 00:00",
"to": "04/09/22 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(())
}Same response shape, same one-week ceiling. There is no page cursor: the window is the pagination. Walking a month means four or five sequential, non-overlapping requests.
A wider window over SOAP
The getDlrReport SOAP method allows a 30-day range reaching a year back — the one thing that interface does better than this one.
Step 3 — Interpret the status
Each transaction carries a status from the DLR vocabulary. This is not the request status scale — 0 and 102 both mean delivered here, while the top-level 0 on the same response only means the report was produced.
Rather than switching on every code, sort them into the four outcomes that drive behaviour:
const DELIVERED = new Set(['0', '102'])
const PENDING = new Set(['-1', '2'])
const BLOCKED = new Set(['15', '16', '17', '18', '201'])
function outcome(status) {
const code = String(status)
if (DELIVERED.has(code)) return 'delivered'
if (PENDING.has(code)) return 'unconfirmed'
if (BLOCKED.has(code)) return 'blocked' // never retry this number
return 'failed'
}
for (const t of report.transactions) {
// The Hebrew field is `message_he` in JSON and `he_message` in XML.
await orders.recordDelivery(t.external_id, outcome(t.status), t.en_message, t.date)
}DELIVERED = {"0", "102"}
PENDING = {"-1", "2"}
BLOCKED = {"15", "16", "17", "18", "201"}
def outcome(status):
code = str(status)
if code in DELIVERED:
return "delivered"
if code in PENDING:
return "unconfirmed"
if code in BLOCKED:
return "blocked" # never retry this number
return "failed"
for t in report["transactions"]:
# The Hebrew field is `message_he` in JSON and `he_message` in XML.
orders.record_delivery(t["external_id"], outcome(t["status"]), t["en_message"], t["date"])<?php
const DELIVERED = ['0', '102'];
const PENDING = ['-1', '2'];
const BLOCKED = ['15', '16', '17', '18', '201'];
function outcome(string|int $status): string
{
$code = (string) $status;
return match (true) {
in_array($code, DELIVERED, true) => 'delivered',
in_array($code, PENDING, true) => 'unconfirmed',
in_array($code, BLOCKED, true) => 'blocked', // never retry
default => 'failed',
};
}
foreach ($report['transactions'] as $t) {
// The Hebrew field is `message_he` in JSON and `he_message` in XML.
$orders->recordDelivery($t['external_id'], outcome($t['status']), $t['en_message'], $t['date']);
}Do not treat -1 as a failure
-1 means sent, no delivery confirmation returned. The message usually did arrive; the carrier simply did not say so. Resending on -1 charges you twice to deliver one message. Keep it in its own bucket and let it age out.
blocked statuses are permanent
15 (kosher handset), 17 (blocked for marketing) and 201 (blocked on request) will not change on a retry. Write them back to your own contact record: 15 means route to voice next time, 201 means stop messaging that person entirely.
Step 4 — Stop polling
Once the loop works, register a push URL and TextMe POSTs each report as it arrives — same fields, form-encoded, no polling.
POST https://your-app.example.com/textme/dlr
Content-Type: application/x-www-form-urlencoded
external_id=order-10052&status=102&en_message=Delivered&date=01/04/26 16:05:05&phone=9725xxxxxxxx&operaor=Telzar&shipment_id=xxxxxxxxxPush is unauthenticated and gives up quietly
No token, no signature — treat the URL as a secret and make handling idempotent.
Worse, a non-200 response starts a retry cycle that stops permanently after several failures, with no notification. Acknowledge first, process afterwards, and alert on the absence of reports rather than waiting to be told.
Full receiver examples in eight languages are on the Push API page.
Step 5 — Keep a reconciliation sweep
Push gives you latency; polling gives you completeness. If your endpoint is down through the retry window, those reports are gone from the feed for good.
Production integrations run both: push for immediacy, plus a periodic dlrByDate sweep over the last day that fills any gaps, matched on external_id.
// Nightly backstop: anything push missed, reconciled by external id.
const report = await textme({
dlrByDate: {
user: { username: 'Leeroy' },
transactions: { external_id: 'null' },
from: format(yesterday, 'dd/MM/yy 00:00'),
to: format(yesterday, 'dd/MM/yy 23:59'),
},
})
for (const t of report.transactions ?? []) {
// Idempotent: a report already recorded by push is a no-op.
await orders.recordDelivery(t.external_id, outcome(t.status), t.en_message, t.date)
}# Nightly backstop: anything push missed, reconciled by external id.
report = textme({
"dlrByDate": {
"user": {"username": "Leeroy"},
"transactions": {"external_id": "null"},
"from": yesterday.strftime("%d/%m/%y 00:00"),
"to": yesterday.strftime("%d/%m/%y 23:59"),
}
})
for t in report.get("transactions", []):
# Idempotent: a report already recorded by push is a no-op.
orders.record_delivery(t["external_id"], outcome(t["status"]), t["en_message"], t["date"])Field naming traps
Three inconsistencies that cost debugging time:
| Field | Polled XML | Polled JSON | Pushed |
|---|---|---|---|
| Hebrew status text | he_message | message_he | he_message |
| Carrier | operator | operator | operaor |
| Date precision | dd/mm/yy hh:mm | dd/mm/yy hh:mm | dd/mm/yy hh:mm:ss |
A parser written against one source will silently return nothing from another. Read both spellings and accept both date shapes.
Recovering from a timed-out send
There is no idempotency key, so a send that times out at the transport level leaves you genuinely unsure whether it went out — and resending risks delivering twice.
The answer is to read rather than write. If every destination carried an id, query dlr for that id over the last hour: a transaction means the message exists, so do not resend. No transaction after a few minutes means it never landed, and resending is safe.
That recovery path only exists if the id was set at send time — which is the real argument for making it a habit.
Next
- Incoming SMS & push — the other direction: replies and inbound messages
- Opt-out & compliance — what to do with
201and blocked statuses - DLR statuses — the complete vocabulary

