OTP
Sends a one-time code by SMS and checks it back. TextMe generates the code, stores it against the phone number, counts the attempts and expires it, so you never handle code generation or storage yourself.
POST https://my.textme.co.il/apiTwo operations: send_otp issues a code, validate_otp verifies one.
Where this comes from
These two operations are documented by TextMe at docs.textme.co.il/otp/, which is a second documentation build on the same host that the main site's own route table does not list. The parameters, payloads and response wording below are the vendor's.
They are also why our status code table carries 12 as "When verifying OTP code: Unverified code".
send_otp Send a one-time code
Generates a code, sends it by SMS, and starts the validity window.
Parameters
| Name | Type | Description | Required |
|---|---|---|---|
send_otp | object | Root element. | ✔️ |
user | object | Contains the user element. | ✔️ |
username | string | The username of the account by which you are recognized in the system. | ✔️ |
phone | int | The destination, formatted 5xxxxxxx or 05xxxxxxx. | ✔️ |
source | string | The sender the code appears to come from. Must be a verified sender. | ✔️ |
app_id | int | Separates authentication between applications when one account serves several. Defaults to 1. | ➖ |
max_tries | int | How many validation attempts the code allows, from 3 to 5. Defaults to 3. | ➖ |
valid_time | int | How many minutes the code stays valid, from 1 to 15. Defaults to 5. | ➖ |
text | string | Your own message text. Must contain [code], which is replaced with the generated code. Defaults to the code is [code]. | ➖ |
Request example
<?xml version="1.0" encoding="UTF-8"?>
<send_otp>
<user>
<username>Leeroy</username>
</user>
<phone>5xxxxxxxx</phone>
<source>DemoAPI</source>
<app_id>1</app_id>
<max_tries>3</max_tries>
<valid_time>5</valid_time>
<text>Your DemoAPI code is [code]</text>
</send_otp>{
"send_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"source": "DemoAPI",
"app_id": "1",
"max_tries": "3",
"valid_time": "5",
"text": "Your DemoAPI code is [code]"
}
}curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
"send_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"source": "DemoAPI",
"app_id": "1",
"max_tries": "3",
"valid_time": "5",
"text": "Your DemoAPI code is [code]"
}
}'// 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({
send_otp: {
user: {
username: 'Leeroy',
},
phone: '5xxxxxxxx',
source: 'DemoAPI',
app_id: '1',
max_tries: '3',
valid_time: '5',
text: 'Your DemoAPI code is [code]',
},
}),
})
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
// 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' => [
'send_otp' => [
'user' => [
'username' => 'Leeroy',
],
'phone' => '5xxxxxxxx',
'source' => 'DemoAPI',
'app_id' => '1',
'max_tries' => '3',
'valid_time' => '5',
'text' => 'Your DemoAPI code is [code]',
],
],
]);
$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
use Illuminate\Support\Facades\Http;
$result = Http::withToken(config('services.textme.token'))
->acceptJson()
->post('https://my.textme.co.il/api', [
'send_otp' => [
'user' => [
'username' => 'Leeroy',
],
'phone' => '5xxxxxxxx',
'source' => 'DemoAPI',
'app_id' => '1',
'max_tries' => '3',
'valid_time' => '5',
'text' => 'Your DemoAPI code is [code]',
],
])
->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);# 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={
"send_otp": {
"user": {
"username": "Leeroy",
},
"phone": "5xxxxxxxx",
"source": "DemoAPI",
"app_id": "1",
"max_tries": "3",
"valid_time": "5",
"text": "Your DemoAPI code is [code]",
},
},
)
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)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]any{
"send_otp": map[string]any{
"user": map[string]any{
"username": "Leeroy",
},
"phone": "5xxxxxxxx",
"source": "DemoAPI",
"app_id": "1",
"max_tries": "3",
"valid_time": "5",
"text": "Your DemoAPI code is [code]",
},
})
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 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 TextMeOtpSend {
public static void main(String[] args) throws Exception {
String body = """
{
"send_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"source": "DemoAPI",
"app_id": "1",
"max_tries": "3",
"valid_time": "5",
"text": "Your DemoAPI code is [code]"
}
}
""";
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());
}
}// .NET 8+ using System.Net.Http
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var payload = """
{
"send_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"source": "DemoAPI",
"app_id": "1",
"max_tries": "3",
"valid_time": "5",
"text": "Your DemoAPI code is [code]"
}
}
""";
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);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({
"send_otp" => {
"user" => {
"username" => "Leeroy",
},
"phone" => "5xxxxxxxx",
"source" => "DemoAPI",
"app_id" => "1",
"max_tries" => "3",
"valid_time" => "5",
"text" => "Your DemoAPI code is [code]",
},
})
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// [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!({
"send_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"source": "DemoAPI",
"app_id": "1",
"max_tries": "3",
"valid_time": "5",
"text": "Your DemoAPI code is [code]"
}
}))
.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(())
}Response
<?xml version="1.0" encoding="utf-8"?>
<sms>
<status>0</status>
<code>448431</code>
<message>The code is : 448431 and is valid for 5 minutes</message>
</sms>{
"status": 0,
"code": "448431",
"message": "The code is : 448431 and is valid for 5 minutes"
}status 0 means the code was generated and the SMS was accepted for sending.
Note the response shape: the root element is sms, not send_otp, and the generated code comes back in code, with the same value repeated inside message.
The response contains the code
send_otp returns the plaintext code to the caller. Anything that logs the response body, an APM trace, an error reporter, a debug log, records a working one-time code for that phone number.
Redact code and message before logging. You do not need either value to complete the flow: the handset receives the code, and validate_otp tells you whether the user typed it correctly.
Errors
| Status | When |
|---|---|
2 | A required element is missing; message names it. |
4 | Not enough credit to send the SMS. |
9 | phone is too short or too long. |
515 | source is not a verified sender. |
989 | text is empty or over the message length limit. |
validate_otp Check a one-time code
Verifies a code the user typed against the one held for that phone and app_id.
Parameters
| Name | Type | Description | Required |
|---|---|---|---|
validate_otp | object | Root element. | ✔️ |
user | object | Contains the user element. | ✔️ |
username | string | The username of the account by which you are recognized in the system. | ✔️ |
phone | int | The number the code was sent to, formatted 5xxxxxxx or 05xxxxxxx. | ✔️ |
code | int | The six-digit code to check. | ✔️ |
app_id | int | Must match the app_id used when sending. Defaults to 1. | ➖ |
service_type | string | Which channel issued the code, sms or whatsapp. Defaults to sms. | ➖ |
Request example
<?xml version="1.0" encoding="UTF-8"?>
<validate_otp>
<user>
<username>Leeroy</username>
</user>
<phone>5xxxxxxxx</phone>
<app_id>1</app_id>
<code>407526</code>
<service_type>sms</service_type>
</validate_otp>{
"validate_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"app_id": "1",
"code": "407526",
"service_type": "sms"
}
}curl --location 'https://my.textme.co.il/api' \
--header "Authorization: Bearer $TEXTME_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
"validate_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"app_id": "1",
"code": "407526",
"service_type": "sms"
}
}'// 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({
validate_otp: {
user: {
username: 'Leeroy',
},
phone: '5xxxxxxxx',
app_id: '1',
code: '407526',
service_type: 'sms',
},
}),
})
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
// 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' => [
'validate_otp' => [
'user' => [
'username' => 'Leeroy',
],
'phone' => '5xxxxxxxx',
'app_id' => '1',
'code' => '407526',
'service_type' => 'sms',
],
],
]);
$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
use Illuminate\Support\Facades\Http;
$result = Http::withToken(config('services.textme.token'))
->acceptJson()
->post('https://my.textme.co.il/api', [
'validate_otp' => [
'user' => [
'username' => 'Leeroy',
],
'phone' => '5xxxxxxxx',
'app_id' => '1',
'code' => '407526',
'service_type' => 'sms',
],
])
->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);# 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={
"validate_otp": {
"user": {
"username": "Leeroy",
},
"phone": "5xxxxxxxx",
"app_id": "1",
"code": "407526",
"service_type": "sms",
},
},
)
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)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]any{
"validate_otp": map[string]any{
"user": map[string]any{
"username": "Leeroy",
},
"phone": "5xxxxxxxx",
"app_id": "1",
"code": "407526",
"service_type": "sms",
},
})
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 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 TextMeOtpValidate {
public static void main(String[] args) throws Exception {
String body = """
{
"validate_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"app_id": "1",
"code": "407526",
"service_type": "sms"
}
}
""";
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());
}
}// .NET 8+ using System.Net.Http
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var payload = """
{
"validate_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"app_id": "1",
"code": "407526",
"service_type": "sms"
}
}
""";
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);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({
"validate_otp" => {
"user" => {
"username" => "Leeroy",
},
"phone" => "5xxxxxxxx",
"app_id" => "1",
"code" => "407526",
"service_type" => "sms",
},
})
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// [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!({
"validate_otp": {
"user": {
"username": "Leeroy"
},
"phone": "5xxxxxxxx",
"app_id": "1",
"code": "407526",
"service_type": "sms"
}
}))
.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(())
}Response
<?xml version="1.0" encoding="utf-8"?>
<sms>
<status>0</status>
<message>The code successfully validated</message>
</sms>{
"status": 0,
"message": "The code successfully validated"
}Errors
| Status | When |
|---|---|
12 | The code did not verify. The documented meaning of 12 on this operation is Unverified code: wrong code, expired code, or attempts exhausted. |
2 | A required element is missing; message names it. |
Status 12 is the one to branch on
12 carries two unrelated meanings depending on the operation: not enough money when sending a message, and unverified code here. Read it against the call you made, never in isolation. See Status codes.
Field notes
app_id separates your applications
One TextMe account can serve several products. Giving each its own app_id keeps their codes independent, so a code issued for app 1000 cannot be validated against app 1001 even for the same phone number.
The value must match between send_otp and validate_otp. Mismatching it is indistinguishable from a wrong code: you get status 12.
Tune max_tries and valid_time together
Defaults are three attempts inside five minutes. Both bound the same risk from different directions:
valid_time(1 to 15 minutes) limits how long a leaked code stays useful. Shorter is safer, but too short and users who switch apps to read the SMS come back to an expired code.max_tries(3 to 5) limits guessing. A six-digit code with five attempts is comfortably safe.
Attempts are counted server-side, so you do not have to track them.
Write the message so the code is findable
text must contain [code]. Leave it out and the recipient gets a message with no code in it.
Put the code early and keep the wording plain, because handsets and password managers auto-detect codes better in short messages. If you use Google's SMS Retriever, the tag field on a normal sms send is the mechanism for the <#> prefix; send_otp has no equivalent, so a flow that needs it should send the code with sms and do its own verification.
What this replaces
Without these two operations you would send a code with sms, store it yourself, expire it yourself and count attempts yourself. send_otp moves all of that server-side: generation, storage, expiry and the attempt counter. You still receive the code in the response, so a support tool can display it if your compliance position allows, but treat that as a deliberate choice rather than a default.
Not covered here
TextMe also documents a WhatsApp OTP call on a separate endpoint (whatsapp-api/send-otp-whatsapp, bearer token, JSON only, no XML), alongside a full WhatsApp API. Neither is covered on this site yet. Both sit outside the SMS API this page belongs to, and service_type above is what ties the two OTP channels together.

