---
url: https://textme-docs.matat.io/endpoints/push.md
description: >-
  Delivery reports, inbound messages and blocklist additions POSTed to a URL you
  own as form-encoded data, with receiver examples in eight languages.
---

# 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](./reports.md) 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.

::: warning 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`](./reports.md) first.
  :::

::: danger 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`](./reports.md) returns, one report per request.

| Field | Description |
|---|---|
| `external_id` | The id you set on the `<phone>` element when [sending](./send.md#external-ids-and-delivery-reports). |
| `status` | The delivery status. See [DLR statuses](../reference/dlr-statuses.md). |
| `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:

```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
```

::: warning `operaor`, not `operator`
The carrier field is misspelled in the push payload. The polled [`dlr`](./reports.md) 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`](./reports.md) 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. |

```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`](./send.md#opt-out-footers).

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

```apache
http://your-app.example.com/textme/blacklist?message=נחסם+מנוי&date=01/04/14 16:05:05&dest=9725xxxxxxxx
```

::: tip 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](../use-cases/opt-out-and-compliance.md).
:::

## 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`.

::: code-group

```js [JavaScript]
// 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]
<?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 [Laravel]
<?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 [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 [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 [C#]
// 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 [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 [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`](./reports.md) 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.
