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 | Push — Push API | |
|---|---|---|
| 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
<?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>{
"incoming": {
"user": {
"username": "Leeroy"
},
"from": "01/11/22 00:00",
"to": "01/11/22 23:59"
}
}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"
}
}'// 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
// 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
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);# 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)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 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());
}
}// .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);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// [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 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>{
"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. |
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:
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=9725xxxxxxxxThe 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.
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 })
})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
// 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
- Return
200fast. Anything else is a failure; after several, TextMe stops pushing to you permanently and silently. Enqueue, acknowledge, process later. - 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.
- 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.
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)
}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.
// 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))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:
// 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 })
}# 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 — what to do once you know someone wants out
- Push API — all three feeds and receiver examples in eight languages
- Track delivery — the outbound half of the same loop

