---
url: https://textme-docs.matat.io/use-cases/incoming-and-push.md
description: >-
  Receiving inbound SMS by polling or push, handling opt-out keywords, and
  normalising the number formats that differ between directions.
---

# Incoming SMS & push

Messages travel both ways. People reply to campaigns, text keywords to your number, and opt out, and TextMe can either hold that traffic for you to poll, or push it to a URL you own as it arrives.

## Two ways to receive

| | Polling ([`incoming`](../endpoints/reports.md) | Push) [Push API](../endpoints/push.md) |
|---|---|---|
| Who initiates | You, on a schedule | TextMe, on arrival |
| Latency | However often you poll | Seconds |
| Completeness | Total. Re-query any window | At-most-once, and silently disabled after repeated failures |
| Needs a public URL | No | Yes |
| Setup | None | Give TextMe the URL |

Most production integrations run **both**: push for latency, a periodic poll as a backstop.

## Polling incoming messages

::: code-group

```xml [XML]
<?xml version="1.0" encoding="UTF-8"?>
<incoming>
    <user>
        <username>Leeroy</username>
    </user>
    <from>01/11/22 00:00</from>
    <to>01/11/22 23:59</to>
</incoming>
```

```json [JSON]
{
  "incoming": {
    "user": {
      "username": "Leeroy"
    },
    "from": "01/11/22 00:00",
    "to": "01/11/22 23:59"
  }
}
```

```bash [cURL]
curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
  "incoming": {
    "user": {
      "username": "Leeroy"
    },
    "from": "01/11/22 00:00",
    "to": "01/11/22 23:59"
  }
}'
```

```js [JavaScript]
// 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({
    incoming: {
      user: {
        username: 'Leeroy',
      },
      from: '01/11/22 00:00',
      to: '01/11/22 23:59',
    },
  }),
})

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 [PHP]
<?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' => [
        'incoming' => [
            'user' => [
                'username' => 'Leeroy',
            ],
            'from' => '01/11/22 00:00',
            'to' => '01/11/22 23:59',
        ],
    ],
]);

$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 [Laravel]
<?php

use Illuminate\Support\Facades\Http;

$result = Http::withToken(config('services.textme.token'))
    ->acceptJson()
    ->post('https://my.textme.co.il/api', [
        'incoming' => [
            'user' => [
                'username' => 'Leeroy',
            ],
            'from' => '01/11/22 00:00',
            'to' => '01/11/22 23:59',
        ],
    ])
    ->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);
```

```python [Python]
# 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={
        "incoming": {
            "user": {
                "username": "Leeroy",
            },
            "from": "01/11/22 00:00",
            "to": "01/11/22 23:59",
        },
    },
)
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)
```

```go [Go]
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	payload, _ := json.Marshal(map[string]any{
		"incoming": map[string]any{
			"user": map[string]any{
				"username": "Leeroy",
			},
			"from": "01/11/22 00:00",
			"to": "01/11/22 23:59",
		},
	})

	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 [Java]
// 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 TextMeIncoming {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "incoming": {
                "user": {
                  "username": "Leeroy"
                },
                "from": "01/11/22 00:00",
                "to": "01/11/22 23:59"
              }
            }
            """;

        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());
    }
}
```

```csharp [C#]
// .NET 8+ using System.Net.Http
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

var payload = """
    {
      "incoming": {
        "user": {
          "username": "Leeroy"
        },
        "from": "01/11/22 00:00",
        "to": "01/11/22 23:59"
      }
    }
    """;

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

```ruby [Ruby]
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({
  "incoming" => {
    "user" => {
      "username" => "Leeroy",
    },
    "from" => "01/11/22 00:00",
    "to" => "01/11/22 23:59",
  },
})

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

```rust [Rust]
// [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!({
          "incoming": {
            "user": {
              "username": "Leeroy"
            },
            "from": "01/11/22 00:00",
            "to": "01/11/22 23:59"
          }
        }))
        .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(())
}
```

:::

::: code-group

```xml [XML]
<?xml version="1.0" encoding="UTF-8"?>
<incoming>
    <status>0</status>
    <message></message>
    <transactions>
        <transaction>
            <source>05********</source>
            <destination>05********</destination>
            <message>This is a sample message</message>
            <date>03/12/14 16:38</date>
        </transaction>
    </transactions>
</incoming>
```

```json [JSON]
{
  "status": 0,
  "message": "",
  "transactions": [
    {
      "source": "test",
      "destination": "9725XXXXXXX",
      "message": "test",
      "date": "01/05/23 00:00"
    }
  ]
}
```

:::

| Field | Description |
|---|---|
| `source` | The number that sent the message. |
| `destination` | The number of yours it arrived on. |
| `message` | The text received. |
| `date` | When it arrived, `dd/mm/yy hh:mm`. |

::: warning Window limits
One week per request, reaching up to **one year** back. As with delivery reports there is no cursor. The window is the pagination, so walk history in non-overlapping slices.
:::

::: tip `source` and `destination` are the other way round from a send
On the way out, `source` is *you*. On the way in, `source` is the *customer* and `destination` is your number. Mixing them up is a classic one-character bug.
:::

## Receiving pushes

Register a URL with TextMe and each inbound message is POSTed to it, form-encoded:

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

message=STOP&date=01/04/26 16:05:05&phone=9725xxxxxxxx&dest=9725xxxxxxxx
```

::: warning The push field names differ from the polled ones
The pushed payload uses `phone` for the sender and `dest` for your number. Where the polled `incoming` response calls them `source` and `destination`. Normalise at the boundary and the rest of your code can stop caring.
:::

::: code-group

```js [JavaScript]
import express from 'express'

const app = express()
app.use(express.urlencoded({ extended: false })) // form-encoded, not JSON

app.post('/textme/incoming', (req, res) => {
  // Push calls them phone/dest; the polled report calls them source/destination.
  const inbound = {
    from: req.body.phone,
    to: req.body.dest,
    text: req.body.message,
    receivedAt: req.body.date,
  }

  // Acknowledge first. A slow or failing response gets the feed disabled.
  res.sendStatus(200)

  queue.push({ kind: 'incoming', ...inbound })
})
```

```python [Python]
from flask import Flask, request

app = Flask(__name__)


@app.post("/textme/incoming")
def incoming():
    # Push calls them phone/dest; the polled report calls them source/destination.
    queue.put({
        "kind": "incoming",
        "from": request.form.get("phone"),
        "to": request.form.get("dest"),
        "text": request.form.get("message"),
        "received_at": request.form.get("date"),
    })

    # Acknowledge first. A slow or failing response gets the feed disabled.
    return "", 200
```

```php [Laravel]
<?php

// routes/web.php. Exclude this path from CSRF verification.
Route::post('/textme/incoming', function (Illuminate\Http\Request $request) {
    // Push calls them phone/dest; the polled report calls them source/destination.
    HandleInboundMessage::dispatch(
        from: $request->input('phone'),
        to: $request->input('dest'),
        text: $request->input('message'),
        receivedAt: $request->input('date'),
    );

    // Queued, not processed. The response goes back immediately.
    return response()->noContent(200);
});
```

:::

::: danger Three rules for any push endpoint

1. **Return `200` fast.** Anything else is a failure; after several, TextMe stops pushing to you *permanently and silently*. Enqueue, acknowledge, process later.
2. **Nothing authenticates the request.** No token, no signature. Use an unguessable path, allowlist source addresses if you can, and never let a push alone trigger something irreversible.
3. **Be idempotent.** A retry is indistinguishable from a new message. Key on `phone` + `date` + `message`.
   :::

## Handling replies

### Opt-out keywords

The single most important thing to handle. When a message carries [`add_unsubscribe`](../endpoints/send.md#opt-out-footers) with value `2`, recipients opt out by replying, and those replies come through this feed.

TextMe adds the number to its own [blocklist](../endpoints/blacklist.md) automatically, so *its* sends stop. What it cannot do is update *your* database, so the contact keeps receiving your email, push notifications and everything else.

::: code-group

```js [JavaScript]
const STOP_WORDS = ['stop', 'הסר', 'הסירו', 'unsubscribe']

function isOptOut(text) {
  return STOP_WORDS.includes(text.trim().toLowerCase())
}

async function handleInbound({ from, text }) {
  if (isOptOut(text)) {
    // TextMe already stopped its own sends. This is about every other channel.
    await contacts.suppress(from, { reason: 'sms-opt-out', channel: 'all' })
    return
  }

  await conversations.record(from, text)
}
```

```python [Python]
STOP_WORDS = {"stop", "הסר", "הסירו", "unsubscribe"}


def is_opt_out(text):
    return text.strip().lower() in STOP_WORDS


def handle_inbound(sender, text):
    if is_opt_out(text):
        # TextMe already stopped its own sends. This covers every other channel.
        contacts.suppress(sender, reason="sms-opt-out", channel="all")
        return

    conversations.record(sender, text)
```

:::

A dedicated [blocklist push feed](../endpoints/push.md#post-blocklist-additions) also fires whenever a subscriber is blocked, however it happened. Link click, reply, or manual action. Consuming that feed is more reliable than keyword-matching, because it catches opt-outs that never appear as an inbound message.

### Number formats differ between directions

Inbound `phone` arrives in international form (`9725xxxxxxxx`. The format you *send* to is local) `05xxxxxxx` or `5xxxxxxx`. They will not match a naive string comparison against your contact table.

::: code-group

```js [JavaScript]
// Inbound is 9725xxxxxxxx; you send to 05xxxxxxxx. Normalise before matching.
function toLocal(phone) {
  const digits = String(phone).replace(/\D/g, '')

  if (digits.startsWith('972')) return `0${digits.slice(3)}`
  if (digits.startsWith('0')) return digits

  return `0${digits}`
}

const contact = await contacts.findByPhone(toLocal(inbound.from))
```

```python [Python]
import re


def to_local(phone):
    """Inbound is 9725xxxxxxxx; you send to 05xxxxxxxx."""
    digits = re.sub(r"\D", "", str(phone))

    if digits.startswith("972"):
        return "0" + digits[3:]
    if digits.startswith("0"):
        return digits

    return "0" + digits


contact = contacts.find_by_phone(to_local(inbound["from"]))
```

:::

### Two-way conversations

TextMe has no conversation or thread concept: an inbound message is a standalone event carrying a phone number and a timestamp. Threading is yours to build. Key on the normalised number, and decide your own idle timeout for when a new message starts a new conversation.

To reply, send a normal [`sms`](../endpoints/send.md) with `source` set to the number they wrote to, so the exchange stays in one thread on the handset.

## Backstop sweep

Push is at-most-once. Anything that arrived while your endpoint was unhealthy is not replayed, so pair it with a periodic poll:

::: code-group

```js [JavaScript]
// Hourly backstop for anything push missed.
const report = await textme({
  incoming: {
    user: { username: 'Leeroy' },
    from: format(hourAgo, 'dd/MM/yy HH:mm'),
    to: format(now, 'dd/MM/yy HH:mm'),
  },
})

for (const t of report.transactions ?? []) {
  // Idempotent on phone + date + text, so re-seeing a message is a no-op.
  await inbox.record({ from: t.source, to: t.destination, text: t.message, at: t.date })
}
```

```python [Python]
# Hourly backstop for anything push missed.
report = textme({
    "incoming": {
        "user": {"username": "Leeroy"},
        "from": hour_ago.strftime("%d/%m/%y %H:%M"),
        "to": now.strftime("%d/%m/%y %H:%M"),
    }
})

for t in report.get("transactions", []):
    # Idempotent on phone + date + text, so re-seeing a message is a no-op.
    inbox.record(sender=t["source"], to=t["destination"], text=t["message"], at=t["date"])
```

:::

Because the poll and the push disagree about field names, normalise both into one internal shape at the edge, as both examples above do.

## Next

* **[Opt-out & compliance](./opt-out-and-compliance.md)**: what to do once you know someone wants out
* **[Push API](../endpoints/push.md)**: all three feeds and receiver examples in eight languages
* **[Track delivery](./delivery-reports.md)**: the outbound half of the same loop
