Skip to content

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 — incomingPush — Push API
Who initiatesYou, on a scheduleTextMe, on arrival
LatencyHowever often you pollSeconds
CompletenessTotal — re-query any windowAt-most-once, and silently disabled after repeated failures
Needs a public URLNoYes
SetupNoneGive TextMe the URL

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

Polling incoming messages

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
{
  "incoming": {
    "user": {
      "username": "Leeroy"
    },
    "from": "01/11/22 00:00",
    "to": "01/11/22 23:59"
  }
}
bash
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
// Node.js 18+ / browsers — 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 — the payload status is what counts
if (Number(result.status) !== 0) {
  throw new Error(`TextMe ${result.status}: ${result.message}`)
}

console.log(result)
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 — the payload status is what counts
if ((int) $result['status'] !== 0) {
    throw new RuntimeException("TextMe {$result['status']}: {$result['message']}");
}

print_r($result);
php
<?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 — the payload status is what counts
throw_if((int) $result['status'] !== 0, RuntimeException::class,
    "TextMe {$result['status']}: {$result['message']}");

logger()->info('TextMe', $result);
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 — the payload status is what counts
if int(result["status"]) != 0:
    raise RuntimeError(f"TextMe {result['status']}: {result['message']}")

print(result)
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 — 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 17+ — 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 — the payload status is what counts
        System.out.println(response.body());
    }
}
csharp
// .NET 8+ — 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 — 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
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 — the payload status is what counts
raise "TextMe #{result['status']}: #{result['message']}" unless result["status"].to_i.zero?

pp result
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 — the payload status is what counts
    if result["status"] != 0 {
        return Err(format!("TextMe {}: {}", result["status"], result["message"]).into());
    }

    println!("{result}");
    Ok(())
}
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
{
  "status": 0,
  "message": "",
  "transactions": [
    {
      "source": "test",
      "destination": "9725XXXXXXX",
      "message": "test",
      "date": "01/05/23 00:00"
    }
  ]
}
FieldDescription
sourceThe number that sent the message.
destinationThe number of yours it arrived on.
messageThe text received.
dateWhen it arrived, dd/mm/yy hh:mm.

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.

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

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.

js
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
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
<?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);
});

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 with value 2, recipients opt out by replying — and those replies come through this feed.

TextMe adds the number to its own blocklist 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.

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

js
// 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
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 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:

js
// 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
# 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