Skip to content

Opt-out & compliance

Israeli law requires marketing messages to carry a working way out, and requires you to honour it. The API gives you the mechanism; this page covers wiring it up so that "unsubscribe" actually means unsubscribed across your whole system.

This is not legal advice

It describes how the API behaves. Israel's Communications Law (the "spam law") sets out what you must actually do, including record-keeping and the burden of proving consent. Talk to whoever advises you on compliance.

Step 1 — Put an exit in the message

add_unsubscribe on sms and bulk appends the removal mechanism:

ValueEffect
3Adds a removal link.
2Adds reply-to-remove instructions.
anything elseNothing is added.
xml
<?xml version="1.0" encoding="UTF-8"?>
<sms>
    <user>
        <username>Leeroy</username>
    </user>
    <source>DemoAPI</source>
    <destinations>
        <cl_id>21518</cl_id>
    </destinations>
    <message>New collection now in store.</message>
    <add_unsubscribe>3</add_unsubscribe>
    <campaign_name>august-newsletter</campaign_name>
</sms>
json
{
  "sms": {
    "user": { "username": "Leeroy" },
    "source": "DemoAPI",
    "destinations": { "cl_id": "21518" },
    "message": "New collection now in store.",
    "add_unsubscribe": "3",
    "campaign_name": "august-newsletter"
  }
}

An invalid value fails the send with status 986 rather than sending without an exit — which is the right way round.

Which value?

3 (link) is one tap and needs no interpretation, so it produces cleaner opt-out data. 2 (reply) works on handsets where following a link is awkward, but arrives as free text you have to keep matching — see handling replies. If you must choose one, choose 3.

The appended text costs characters against the 1005-character limit. Leave room.

Step 2 — Let TextMe suppress its own sends

When someone opts out, TextMe adds the number to the account's blocklist and stops delivering to it. You do not have to filter destinations yourself:

  • Blocked numbers are dropped at send time.
  • A send whose destinations are all blocked fails with status 8 rather than reporting a hollow success.
  • Delivery reports for blocked destinations come back as status 201, blocked on request.

That covers SMS. It does not cover anything else you send.

Step 3 — Propagate the opt-out to your own systems

This is the step most integrations miss

TextMe stops its messages. Your email, your push notifications, your call centre and any other SMS provider you use know nothing about it. Someone who asked to be left alone and keeps hearing from you on three other channels has a legitimate complaint — and, on paper, you honoured the request.

Consume the blocklist push feed, which fires whenever a number is blocked however it happened — link, reply, or manual action:

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

message=נחסם+מנוי&date=01/04/26 16:05:05&dest=9725xxxxxxxx
js
app.post('/textme/blacklist', (req, res) => {
  const phone = toLocal(req.body.dest) // 9725xxxxxxxx → 05xxxxxxxx
  const at = req.body.date

  // Acknowledge before doing the work — a slow reply gets the feed disabled.
  res.sendStatus(200)

  queue.push({ kind: 'opt-out', phone, at })
})

// In the worker: suppress everywhere, not just SMS.
async function handleOptOut({ phone, at }) {
  await contacts.suppress(phone, {
    channels: ['sms', 'email', 'push', 'voice'],
    reason: 'sms-opt-out',
    recordedAt: at,
  })
}
python
@app.post("/textme/blacklist")
def blacklisted():
    queue.put({
        "kind": "opt-out",
        "phone": to_local(request.form.get("dest")),  # 9725… → 05…
        "at": request.form.get("date"),
    })

    # Acknowledge before doing the work.
    return "", 200


# In the worker: suppress everywhere, not just SMS.
def handle_opt_out(phone, at):
    contacts.suppress(
        phone,
        channels=["sms", "email", "push", "voice"],
        reason="sms-opt-out",
        recorded_at=at,
    )
php
<?php

Route::post('/textme/blacklist', function (Illuminate\Http\Request $request) {
    // 9725xxxxxxxx → 05xxxxxxxx before it touches your contact table.
    SuppressContact::dispatch(
        phone: to_local($request->input('dest')),
        recordedAt: $request->input('date'),
    );

    return response()->noContent(200);
});

Remember that dest arrives in international form (9725xxxxxxxx) while your records almost certainly hold the local form — normalise before matching, as in Incoming SMS & push.

Step 4 — Keep the audit trail

Two things are worth recording for every opt-out, because they are what you would need to produce if challenged:

  • When it happened. Both the push feed and the blacklist report carry the date.
  • When and how consent was given in the first place. The API cannot tell you this — it lives in your own signup records.

The blocklist report gives you a periodic reconciliation:

xml
<?xml version="1.0" encoding="UTF-8"?>
<blacklist>
    <user>
        <username>XXXXXX</username>
    </user>
    <from>02/06/22 00:00</from>
    <to>15/12/22 23:59</to>
</blacklist>
json
{
  "blacklist": {
    "user": {
      "username": "XXXXXX"
    },
    "from": "02/06/22 00:00",
    "to": "15/12/22 23:59"
  }
}
bash
curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
  "blacklist": {
    "user": {
      "username": "XXXXXX"
    },
    "from": "02/06/22 00:00",
    "to": "15/12/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({
    blacklist: {
      user: {
        username: 'XXXXXX',
      },
      from: '02/06/22 00:00',
      to: '15/12/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' => [
        'blacklist' => [
            'user' => [
                'username' => 'XXXXXX',
            ],
            'from' => '02/06/22 00:00',
            'to' => '15/12/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', [
        'blacklist' => [
            'user' => [
                'username' => 'XXXXXX',
            ],
            'from' => '02/06/22 00:00',
            'to' => '15/12/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={
        "blacklist": {
            "user": {
                "username": "XXXXXX",
            },
            "from": "02/06/22 00:00",
            "to": "15/12/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{
		"blacklist": map[string]any{
			"user": map[string]any{
				"username": "XXXXXX",
			},
			"from": "02/06/22 00:00",
			"to": "15/12/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 TextMeBlacklistGet {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "blacklist": {
                "user": {
                  "username": "XXXXXX"
                },
                "from": "02/06/22 00:00",
                "to": "15/12/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 = """
    {
      "blacklist": {
        "user": {
          "username": "XXXXXX"
        },
        "from": "02/06/22 00:00",
        "to": "15/12/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({
  "blacklist" => {
    "user" => {
      "username" => "XXXXXX",
    },
    "from" => "02/06/22 00:00",
    "to" => "15/12/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!({
          "blacklist": {
            "user": {
              "username": "XXXXXX"
            },
            "from": "02/06/22 00:00",
            "to": "15/12/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"?>
<blacklist>
    <status>0</status>
    <message></message>
    <transactions>
        <transaction>
            <phone>5********</phone>
            <date>03/12/14 16:38</date>
        </transaction>
    </transactions>
</blacklist>
json
{
  "status": 0,
  "message": "",
  "transactions": [
    {
      "phone": "5********",
      "date": "22/08/22 11:00"
    },
    {
      "phone": "5********",
      "date": "23/11/22 12:37"
    }
  ]
}

Numbers come back masked

Entries are returned as 5********, not in full. The report is an audit of how many opted out and when — it is not a list you can diff against your own database. Use the push feed for that, and rely on the API to enforce the blocklist at send time.

Step 5 — Block on request, without waiting for a reply

Customers ask to be removed by phone, by email, at the counter. Add them yourself:

xml
<?xml version="1.0" encoding="UTF-8"?>
<addNumBL>
    <user>
        <username>xxxxxx</username>
    </user>
    <phones>
        <phone>5********</phone>
        <phone>05********</phone>
    </phones>
</addNumBL>
json
{
  "addNumBL": {
    "user": {
      "username": "xxxxxx"
    },
    "phones": {
      "phone": [
        "5********",
        "05********"
      ]
    }
  }
}
bash
curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
  "addNumBL": {
    "user": {
      "username": "xxxxxx"
    },
    "phones": {
      "phone": [
        "5********",
        "05********"
      ]
    }
  }
}'
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({
    addNumBL: {
      user: {
        username: 'xxxxxx',
      },
      phones: {
        phone: [
          '5********',
          '05********',
        ],
      },
    },
  }),
})

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' => [
        'addNumBL' => [
            'user' => [
                'username' => 'xxxxxx',
            ],
            'phones' => [
                'phone' => [
                    '5********',
                    '05********',
                ],
            ],
        ],
    ],
]);

$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', [
        'addNumBL' => [
            'user' => [
                'username' => 'xxxxxx',
            ],
            'phones' => [
                'phone' => [
                    '5********',
                    '05********',
                ],
            ],
        ],
    ])
    ->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={
        "addNumBL": {
            "user": {
                "username": "xxxxxx",
            },
            "phones": {
                "phone": [
                    "5********",
                    "05********",
                ],
            },
        },
    },
)
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{
		"addNumBL": map[string]any{
			"user": map[string]any{
				"username": "xxxxxx",
			},
			"phones": map[string]any{
				"phone": []any{
					"5********",
					"05********",
				},
			},
		},
	})

	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 TextMeBlacklistAdd {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "addNumBL": {
                "user": {
                  "username": "xxxxxx"
                },
                "phones": {
                  "phone": [
                    "5********",
                    "05********"
                  ]
                }
              }
            }
            """;

        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 = """
    {
      "addNumBL": {
        "user": {
          "username": "xxxxxx"
        },
        "phones": {
          "phone": [
            "5********",
            "05********"
          ]
        }
      }
    }
    """;

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({
  "addNumBL" => {
    "user" => {
      "username" => "xxxxxx",
    },
    "phones" => {
      "phone" => [
        "5********",
        "05********",
      ],
    },
  },
})

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!({
          "addNumBL": {
            "user": {
              "username": "xxxxxx"
            },
            "phones": {
              "phone": [
                "5********",
                "05********"
              ]
            }
          }
        }))
        .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(())
}

Success is 946, not 0

addNumBL answers 946number has successfully added to blacklist. Code that treats any non-zero status as a failure will report a working call as broken, and may retry it pointlessly.

Unblocking

Removing someone from the blocklist means messaging a person who previously asked you to stop. rmNumBL therefore requires a reason:

xml
<?xml version="1.0" encoding="UTF-8"?>
<rmNumBL>
    <user>
        <username>xxxxxx</username>
    </user>
    <phones>
        <phone>5********</phone>
        <phone>05********</phone>
    </phones>
    <reason>Customer opted back in by phone</reason>
</rmNumBL>
json
{
  "rmNumBL": {
    "user": {
      "username": "xxxxxx"
    },
    "phones": {
      "phone": [
        "5********",
        "05********"
      ]
    },
    "reason": "Customer opted back in by phone"
  }
}
bash
curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
  "rmNumBL": {
    "user": {
      "username": "xxxxxx"
    },
    "phones": {
      "phone": [
        "5********",
        "05********"
      ]
    },
    "reason": "Customer opted back in by phone"
  }
}'
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({
    rmNumBL: {
      user: {
        username: 'xxxxxx',
      },
      phones: {
        phone: [
          '5********',
          '05********',
        ],
      },
      reason: 'Customer opted back in by phone',
    },
  }),
})

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' => [
        'rmNumBL' => [
            'user' => [
                'username' => 'xxxxxx',
            ],
            'phones' => [
                'phone' => [
                    '5********',
                    '05********',
                ],
            ],
            'reason' => 'Customer opted back in by phone',
        ],
    ],
]);

$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', [
        'rmNumBL' => [
            'user' => [
                'username' => 'xxxxxx',
            ],
            'phones' => [
                'phone' => [
                    '5********',
                    '05********',
                ],
            ],
            'reason' => 'Customer opted back in by phone',
        ],
    ])
    ->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={
        "rmNumBL": {
            "user": {
                "username": "xxxxxx",
            },
            "phones": {
                "phone": [
                    "5********",
                    "05********",
                ],
            },
            "reason": "Customer opted back in by phone",
        },
    },
)
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{
		"rmNumBL": map[string]any{
			"user": map[string]any{
				"username": "xxxxxx",
			},
			"phones": map[string]any{
				"phone": []any{
					"5********",
					"05********",
				},
			},
			"reason": "Customer opted back in by phone",
		},
	})

	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 TextMeBlacklistRemove {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "rmNumBL": {
                "user": {
                  "username": "xxxxxx"
                },
                "phones": {
                  "phone": [
                    "5********",
                    "05********"
                  ]
                },
                "reason": "Customer opted back in by phone"
              }
            }
            """;

        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 = """
    {
      "rmNumBL": {
        "user": {
          "username": "xxxxxx"
        },
        "phones": {
          "phone": [
            "5********",
            "05********"
          ]
        },
        "reason": "Customer opted back in by phone"
      }
    }
    """;

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({
  "rmNumBL" => {
    "user" => {
      "username" => "xxxxxx",
    },
    "phones" => {
      "phone" => [
        "5********",
        "05********",
      ],
    },
    "reason" => "Customer opted back in by phone",
  },
})

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!({
          "rmNumBL": {
            "user": {
              "username": "xxxxxx"
            },
            "phones": {
              "phone": [
                "5********",
                "05********"
              ]
            },
            "reason": "Customer opted back in by phone"
          }
        }))
        .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(())
}

Its success statuses are also unusual: 0 when every number was removed, and 944 when some were not on the list to begin with.

Only unblock with evidence

Do it when you have a record of the person asking to come back — a fresh signup, a written request. reason is where that record goes. The burden of proving consent sits with the sender, so "they probably don't mind" is not a basis for re-subscribing anyone.

Not everything that stops a message is an opt-out. These are worth distinguishing in your own reporting:

MechanismWhat it is
temp_blYour choice to skip anyone messaged in the last 1–14 days. Frequency capping, not consent. Filtering everyone out returns status 715.
DLR 17The subscriber is blocked for marketing messages at the carrier or regulator level.
DLR 201Blocked at the subscriber's own request — the opt-out showing up in delivery data.
DLR 15A kosher (filtered) handset. Not a refusal — reach them with voice instead.
Status 8Every destination in the send was blocklisted. An audience problem, not a code fault.

15 deserves the emphasis: treating it as an opt-out throws away a reachable customer. It means this device does not do SMS, and tts.type 2 routes exactly those numbers to a voice call automatically.

A working checklist

  1. Every marketing send carries add_unsubscribe3 unless you have a reason.
  2. The blocklist push feed is consumed, and suppresses across all channels.
  3. 946 and 944 are treated as success in your client.
  4. Inbound STOP / הסר keywords are handled as a second path, for the 2 case.
  5. DLR 201 and 17 write back to the contact record.
  6. DLR 15 routes to voice rather than being marked as failed.
  7. Status 8 raises an audience alert, not a code alert.
  8. Unblocking requires a recorded reason.

Next