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 — removing 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.

