Push API
The inverse of the rest of this reference: instead of you calling TextMe, TextMe calls a URL you own. Three feeds can be pushed (delivery reports, incoming messages, and blocklist additions), which removes the need to poll Reports at all.
You supply the URL to TextMe; there is no API operation to register it.
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.
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.

