Skip to content

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

http
POST https://your-app.example.com/textme/dlr
Content-Type: application/x-www-form-urlencoded

Each 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_id or on phone + date — because a retried push is indistinguishable from a new one.
  • Never act on a push alone for anything irreversible; confirm against dlr first.

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.

FieldDescription
external_idThe id you set on the <phone> element when sending.
statusThe delivery status — see DLR statuses.
he_messageThe status in Hebrew.
en_messageThe status in English.
dateWhen the status was recorded, dd/mm/yy hh:mm:ss.
phoneThe destination, in international form, e.g. 9725xxxxxxxx.
operaorThe carrier that handled it. Spelled without the t — see the note below.
shipment_idThe campaign the message belonged to.

As a URL, the equivalent looks like:

apache
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=xxxxxxxxx

operaor, 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.

FieldDescription
messageThe text received.
dateWhen it arrived, dd/mm/yy hh:mm:ss.
phoneThe number that sent it.
destThe number of yours it arrived on.
apache
http://your-app.example.com/textme/incoming?message=This+is+a+sample+message&date=01/04/14 16:05:05&phone=9725xxxxxxxx&dest=9725xxxxxxxx

POST Blocklist additions

Sent when a subscriber is blocked — normally because they opted out of a message that carried add_unsubscribe.

FieldDescription
messageA note about the block, in Hebrew — e.g. נחסם מנוי ("subscriber blocked").
dateWhen it happened, dd/mm/yy hh:mm:ss.
destThe number that was blocked.
apache
http://your-app.example.com/textme/blacklist?message=נחסם+מנוי&date=01/04/14 16:05:05&dest=9725xxxxxxxx

This 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.

js
// 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
<?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
<?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);
});
python
# 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 "", 200
go
package 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))
}
csharp
// 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();
ruby
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
java
// 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.