Push API
The inverse of the rest of this reference: instead of you calling TextMe, TextMe calls a URL you own. Five feeds can be pushed, which removes the need to poll Reports at all.
Register a URL yourself, either with the push_url operation or in the console under Settings, Push API. You do not need to ask TextMe.
Registering a URL
The page is headed URL assignment to. Pick a user in the Users list on the left, then use Addition of URL. Registered URLs appear in a table with a Service column, so a URL is bound to a particular feed rather than catching all of them, and a user can hold more than one.
The URL type dropdown offers exactly five values:
| URL type | Feed |
|---|---|
dlr | Delivery reports for SMS |
incoming_sms | Inbound SMS |
blacklist | Additions to the blocklist |
dlr_whatsapp | Delivery reports for WhatsApp |
incoming_whatsapp | Inbound WhatsApp |
The two WhatsApp feeds are separate registrations. A dlr URL does not receive WhatsApp delivery reports.
URLs are per user, not per account
The Users panel is the point of the screen. On an account with several users, each one carries its own URLs, and you register them by selecting the user first. A parent account with many sub-users registers each separately; there is no bulk import on this page.
The table also carries a Status column and an Actions column, which is where a feed that has been switched off after repeated delivery failures shows up and where it is dealt with.
Register a push_url
Does the same job as the console screen above, without opening it. One call registers one URL for one feed.
POST https://my.textme.co.il/apiParameters
| Name | Type | Description | Required |
|---|---|---|---|
push_url | object | Contains all other elements. | ✔️ |
user | object | Contains the user element. | ✔️ |
username | string | The username of the account by which you are recognized in the system. | ✔️ |
type | string | The push you wish to receive at this url. One of dlr, incoming_sms, blacklist, dlr_whatsapp, incoming_whatsapp. | ✔️ |
url | string | The url we will push to. Must be a public http or https address. | ✔️ |
Request example
<?xml version="1.0" encoding="UTF-8"?>
<push_url>
<user>
<username>Leeroy</username>
</user>
<type>dlr</type>
<url>https://www.example.com/path/to/resource</url>
</push_url>{
"push_url": {
"user": {
"username": "Leeroy"
},
"type": "dlr",
"url": "https://www.example.com/path/to/resource"
}
}curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
"push_url": {
"user": {
"username": "Leeroy"
},
"type": "dlr",
"url": "https://www.example.com/path/to/resource"
}
}'// Node.js 18+ or any modern browser. 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({
push_url: {
user: {
username: 'Leeroy',
},
type: 'dlr',
url: 'https://www.example.com/path/to/resource',
},
}),
})
const result = await response.json()
// Errors arrive as HTTP 200 too, so 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' => [
'push_url' => [
'user' => [
'username' => 'Leeroy',
],
'type' => 'dlr',
'url' => 'https://www.example.com/path/to/resource',
],
],
]);
$result = json_decode($response->getBody()->getContents(), true);
// Errors arrive as HTTP 200 too, so 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', [
'push_url' => [
'user' => [
'username' => 'Leeroy',
],
'type' => 'dlr',
'url' => 'https://www.example.com/path/to/resource',
],
])
->throw()
->json();
// Errors arrive as HTTP 200 too, so 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={
"push_url": {
"user": {
"username": "Leeroy",
},
"type": "dlr",
"url": "https://www.example.com/path/to/resource",
},
},
)
response.raise_for_status()
result = response.json()
# Errors arrive as HTTP 200 too, so 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{
"push_url": map[string]any{
"user": map[string]any{
"username": "Leeroy",
},
"type": "dlr",
"url": "https://www.example.com/path/to/resource",
},
})
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, so 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+ using 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 TextMePushUrlAdd {
public static void main(String[] args) throws Exception {
String body = """
{
"push_url": {
"user": {
"username": "Leeroy"
},
"type": "dlr",
"url": "https://www.example.com/path/to/resource"
}
}
""";
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, so the payload status is what counts
System.out.println(response.body());
}
}// .NET 8+ using System.Net.Http
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var payload = """
{
"push_url": {
"user": {
"username": "Leeroy"
},
"type": "dlr",
"url": "https://www.example.com/path/to/resource"
}
}
""";
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, so 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({
"push_url" => {
"user" => {
"username" => "Leeroy",
},
"type" => "dlr",
"url" => "https://www.example.com/path/to/resource",
},
})
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, so 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!({
"push_url": {
"user": {
"username": "Leeroy"
},
"type": "dlr",
"url": "https://www.example.com/path/to/resource"
}
}))
.send()
.await?
.json()
.await?;
// Errors arrive as HTTP 200 too, so the payload status is what counts
if result["status"] != 0 {
return Err(format!("TextMe {}: {}", result["status"], result["message"]).into());
}
println!("{result}");
Ok(())
}Response
<?xml version="1.0" encoding="utf-8"?>
<sms>
<status>0</status>
<message>push url has successfully added</message>
</sms>{
"status": 0,
"message": "push url has successfully added"
}This adds a URL. It never changes one.
If the account already has a URL for that type, the call returns status 517 and nothing is changed. The old URL stays exactly where it was.
To move a feed somewhere else, delete the existing entry in the console first, then call push_url.
This is worth getting right, because the failure is quiet. A deployment script that re-registers every time will get 517, carry on as though it succeeded, and leave the feed pointing at the previous address. Your new endpoint then receives nothing, and looks broken when it is not.
Each feed is registered separately. If you want all five, that is five calls.
How a push arrives
POST https://your-app.example.com/textme/dlr
Content-Type: application/x-www-form-urlencodedEach feed posts the same field names as the polled XML response, flattened: form fields, not XML, not JSON. Read them exactly as you would read an HTML form submission.
Nothing authenticates the request
A push carries no token, signature or shared secret. Anyone who learns your URL can post to it. Defend it yourself:
- Use an unguessable path, and treat it as a credential.
- Allowlist TextMe's source addresses at the edge if you can.
- Make handling idempotent (key on
external_idor onphone+date) because a retried push is indistinguishable from a new one. - Never act on a push alone for anything irreversible; confirm against
dlrfirst.
Answer 200, and answer quickly
Any response other than 200 OK is treated as a failure. TextMe holds the push and retries for a while, then stops pushing to your URL automatically after several failed attempts, and nothing tells you it stopped.
Acknowledge first, process afterwards: write the payload to a queue, return 200, and do the real work outside the request.
Source addresses
All feeds are sent from these addresses:
46.31.96.138
46.31.96.222
46.31.97.35
46.31.97.108
46.31.97.109
46.31.97.205Allowlisting them at your edge is the only identity check available, because the request itself carries no credential.
Which to allowlist, the hosts or the blocks
The six addresses sit in two adjacent /24s at two sites, which is why there are six rather than one:
| Block | Site | Addresses in the list |
|---|---|---|
46.31.96.0/24 | Telzar 019 Infrastructure, Petah Tikva | .138, .222 |
46.31.97.0/24 | Telzar 019 Infrastructure, Haifa | .35, .108, .109, .205 |
Both are ASSIGNED PA to TELZAR-INFRA1, maintained by Telzar-MNT, inside 46.31.96.0/21 announced by AS51825 with an RPKI ROA.
That gives you a choice:
- The six
/32s are exact, and the tightest rule you can write. They are also individual hosts, and no notice period is published for changing them. - The two
/24s are registry allocations. They do not move unless the allocation itself changes, so they survive a host being renumbered. The cost is that you also accept other Telzar infrastructure hosts in those blocks.
Pick the /32s if you can tolerate re-checking the list, the /24s if an unexplained gap in your delivery reports would be worse than a slightly wider rule.
No notice period is published for this list
TextMe states no notice period for changes to these addresses. If you hard-block on them, treat an unexplained silence in the feed as a possible cause and confirm with support before concluding that your own endpoint is at fault.
POST Delivery reports
Sent as each delivery status arrives. The same information dlr returns, one report per request.
| Field | Description |
|---|---|
external_id | The id you set on the <phone> element when sending. |
status | The delivery status. See DLR statuses. |
he_message | The status in Hebrew. |
en_message | The status in English. |
date | When the status was recorded, dd/mm/yy hh:mm:ss. |
phone | The destination, in international form, e.g. 9725xxxxxxxx. |
operaor | The carrier that handled it. Spelled without the t: see the note below. |
shipment_id | The campaign the message belonged to. |
As a URL, the equivalent looks like:
http://your-app.example.com/textme/dlr?external_id=1234&status=102&he_message=הגיע+ליעד&en_message=Delivered
&date=01/04/14 16:05:05&phone=9725xxxxxxxx&operaor=Telzar&shipment_id=xxxxxxxxxoperaor, not operator
The carrier field is misspelled in the push payload. The polled dlr response spells it operator. Code that reads reports from both sources needs to accept both spellings, reading only operator from a push silently yields nothing.
POST Incoming messages
Sent when someone messages one of your numbers. The same information incoming returns.
| Field | Description |
|---|---|
message | The text received. |
date | When it arrived, dd/mm/yy hh:mm:ss. |
phone | The number that sent it. |
dest | The number of yours it arrived on. |
http://your-app.example.com/textme/incoming?message=This+is+a+sample+message&date=01/04/14 16:05:05&phone=9725xxxxxxxx&dest=9725xxxxxxxxPOST Blocklist additions
Sent when a subscriber is blocked, normally because they opted out of a message that carried add_unsubscribe.
| Field | Description |
|---|---|
message | A note about the block, in Hebrew. e.g. נחסם מנוי ("subscriber blocked"). |
date | When it happened, dd/mm/yy hh:mm:ss. |
dest | The number that was blocked. |
http://your-app.example.com/textme/blacklist?message=נחסם+מנוי&date=01/04/14 16:05:05&dest=9725xxxxxxxxThis is the opt-out signal worth wiring up first
TextMe already suppresses blocked numbers on its own side. The reason to consume this feed is your database, so the contact stops receiving mail, push and everything else you send them, not just SMS. See Opt-out & compliance.
Receiving a push
Acknowledge immediately, then process. Every example below does the same three things: read the form fields, hand them to a queue, return 200.
// Express. Note express.urlencoded, not express.json
import express from 'express'
const app = express()
app.use(express.urlencoded({ extended: false }))
app.post('/textme/dlr', (req, res) => {
const { external_id, status, en_message, date, phone, shipment_id } = req.body
// `operaor` is the push spelling; `operator` is the polled-report spelling.
const carrier = req.body.operaor ?? req.body.operator
// Acknowledge first, anything slower risks the retry-then-disable cycle.
res.sendStatus(200)
queue.push({ external_id, status, en_message, date, phone, carrier, shipment_id })
})
app.post('/textme/incoming', (req, res) => {
const { message, date, phone, dest } = req.body
res.sendStatus(200)
queue.push({ kind: 'incoming', message, date, from: phone, to: dest })
})
app.post('/textme/blacklist', (req, res) => {
const { dest, date } = req.body
res.sendStatus(200)
queue.push({ kind: 'opt-out', phone: dest, date })
})<?php
// Plain PHP. The payload is form-encoded, so it lands in $_POST.
$report = [
'external_id' => $_POST['external_id'] ?? null,
'status' => $_POST['status'] ?? null,
'he_message' => $_POST['he_message'] ?? null,
'en_message' => $_POST['en_message'] ?? null,
'date' => $_POST['date'] ?? null,
'phone' => $_POST['phone'] ?? null,
// `operaor` is the push spelling; `operator` is the polled-report spelling.
'carrier' => $_POST['operaor'] ?? $_POST['operator'] ?? null,
'shipment_id' => $_POST['shipment_id'] ?? null,
];
// Acknowledge before doing any real work.
http_response_code(200);
header('Content-Length: 0');
header('Connection: close');
flush();
queue_delivery_report($report);<?php
// routes/web.php. Exclude the path from CSRF verification.
Route::post('/textme/dlr', function (Illuminate\Http\Request $request) {
ProcessDeliveryReport::dispatch([
'external_id' => $request->input('external_id'),
'status' => $request->input('status'),
'en_message' => $request->input('en_message'),
'date' => $request->input('date'),
'phone' => $request->input('phone'),
// `operaor` is the push spelling; `operator` is the polled one.
'carrier' => $request->input('operaor', $request->input('operator')),
'shipment_id' => $request->input('shipment_id'),
]);
// Queued, not processed. The response goes back immediately.
return response()->noContent(200);
});
Route::post('/textme/blacklist', function (Illuminate\Http\Request $request) {
SuppressContact::dispatch($request->input('dest'), $request->input('date'));
return response()->noContent(200);
});# Flask, request.form, not request.json
from flask import Flask, request
app = Flask(__name__)
@app.post("/textme/dlr")
def delivery_report():
form = request.form
queue.put({
"external_id": form.get("external_id"),
"status": form.get("status"),
"en_message": form.get("en_message"),
"date": form.get("date"),
"phone": form.get("phone"),
# `operaor` is the push spelling; `operator` is the polled one.
"carrier": form.get("operaor") or form.get("operator"),
"shipment_id": form.get("shipment_id"),
})
# Acknowledge immediately; the worker does the rest.
return "", 200
@app.post("/textme/blacklist")
def opt_out():
queue.put({"kind": "opt-out", "phone": request.form.get("dest")})
return "", 200package main
import (
"log"
"net/http"
)
func deliveryReport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
// Still acknowledge: a non-200 starts the retry-then-disable cycle.
w.WriteHeader(http.StatusOK)
log.Println("textme: unparsable push:", err)
return
}
// `operaor` is the push spelling; `operator` is the polled-report spelling.
carrier := r.FormValue("operaor")
if carrier == "" {
carrier = r.FormValue("operator")
}
report := map[string]string{
"external_id": r.FormValue("external_id"),
"status": r.FormValue("status"),
"en_message": r.FormValue("en_message"),
"date": r.FormValue("date"),
"phone": r.FormValue("phone"),
"carrier": carrier,
"shipment_id": r.FormValue("shipment_id"),
}
w.WriteHeader(http.StatusOK)
go enqueue(report)
}
func main() {
http.HandleFunc("/textme/dlr", deliveryReport)
log.Fatal(http.ListenAndServe(":8080", nil))
}// ASP.NET Core minimal API
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/textme/dlr", async (HttpRequest request, IReportQueue queue) =>
{
var form = await request.ReadFormAsync();
// `operaor` is the push spelling; `operator` is the polled-report spelling.
var carrier = form["operaor"].FirstOrDefault() ?? form["operator"].FirstOrDefault();
// Enqueue rather than process. The response must not wait on work.
queue.Enqueue(new DeliveryReport(
ExternalId: form["external_id"],
Status: form["status"],
EnMessage: form["en_message"],
Date: form["date"],
Phone: form["phone"],
Carrier: carrier,
ShipmentId: form["shipment_id"]));
return Results.Ok();
});
app.Run();require "sinatra"
post "/textme/dlr" do
# `operaor` is the push spelling; `operator` is the polled-report spelling.
carrier = params["operaor"] || params["operator"]
Queue.push(
external_id: params["external_id"],
status: params["status"],
en_message: params["en_message"],
date: params["date"],
phone: params["phone"],
carrier: carrier,
shipment_id: params["shipment_id"],
)
# Acknowledge immediately.
status 200
body ""
end
post "/textme/blacklist" do
Queue.push(kind: "opt-out", phone: params["dest"], date: params["date"])
status 200
body ""
end// Spring Boot. @RequestParam reads form-encoded fields
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
public class TextMePushController {
private final ReportQueue queue;
public TextMePushController(ReportQueue queue) {
this.queue = queue;
}
@PostMapping("/textme/dlr")
public ResponseEntity<Void> deliveryReport(
@RequestParam(required = false) String external_id,
@RequestParam(required = false) String status,
@RequestParam(required = false) String en_message,
@RequestParam(required = false) String date,
@RequestParam(required = false) String phone,
// `operaor` is the push spelling; `operator` is the polled one.
@RequestParam(required = false) String operaor,
@RequestParam(required = false) String operator,
@RequestParam(required = false) String shipment_id) {
String carrier = operaor != null ? operaor : operator;
queue.enqueue(external_id, status, en_message, date, phone, carrier, shipment_id);
// Acknowledge immediately; the queue does the work.
return ResponseEntity.ok().build();
}
}Field notes
Push does not replace polling entirely
A push tells you about one event, once, and if your endpoint was down through the retry window, that event is gone from the feed. Keep a periodic dlrByDate sweep over the last day as a backstop, reconciled by external_id. Push gives you latency; polling gives you completeness.
Dates arrive with seconds
Push payloads carry dd/mm/yy hh:mm:ss. One component longer than the dd/mm/yy hh:mm you send in request parameters. A parser written strictly against the request format will reject them.
Values are URL-encoded, including Hebrew
he_message=הגיע+ליעד arrives percent-encoded with + for spaces. Any standard form-body parser handles this; hand-rolled splitting on & and = will not.
Detecting that pushes stopped
Because disabling is silent, the failure mode is a feed that simply goes quiet, which looks exactly like a quiet day. Alert on the absence of pushes: if a campaign went out an hour ago and no report has arrived, something is wrong with the endpoint, not the campaign.

