Skip to content

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.

http
POST https://my.textme.co.il/api

Two 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

NameTypeDescriptionRequired
send_otpobjectRoot element.✔️
userobjectContains the user element.✔️
usernamestringThe username of the account by which you are recognized in the system.✔️
phoneintThe destination, formatted 5xxxxxxx or 05xxxxxxx.✔️
sourcestringThe sender the code appears to come from. Must be a verified sender.✔️
app_idintSeparates authentication between applications when one account serves several. Defaults to 1.
max_triesintHow many validation attempts the code allows, from 3 to 5. Defaults to 3.
valid_timeintHow many minutes the code stays valid, from 1 to 15. Defaults to 5.
textstringYour own message text. Must contain [code], which is replaced with the generated code. Defaults to the code is [code].

Request example

xml
<?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>
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]"
  }
}
bash
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]"
  }
}'
js
// 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
<?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
<?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);
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={
        "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)
go
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
// 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());
    }
}
csharp
// .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);
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({
  "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
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!({
          "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
<?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>
json
{
  "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

StatusWhen
2A required element is missing; message names it.
4Not enough credit to send the SMS.
9phone is too short or too long.
515source is not a verified sender.
989text 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

NameTypeDescriptionRequired
validate_otpobjectRoot element.✔️
userobjectContains the user element.✔️
usernamestringThe username of the account by which you are recognized in the system.✔️
phoneintThe number the code was sent to, formatted 5xxxxxxx or 05xxxxxxx.✔️
codeintThe six-digit code to check.✔️
app_idintMust match the app_id used when sending. Defaults to 1.
service_typestringWhich channel issued the code, sms or whatsapp. Defaults to sms.

Request example

xml
<?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>
json
{
  "validate_otp": {
    "user": {
      "username": "Leeroy"
    },
    "phone": "5xxxxxxxx",
    "app_id": "1",
    "code": "407526",
    "service_type": "sms"
  }
}
bash
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"
  }
}'
js
// 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
<?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
<?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);
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={
        "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)
go
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
// 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());
    }
}
csharp
// .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);
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({
  "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
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!({
          "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
<?xml version="1.0" encoding="utf-8"?>
<sms>
    <status>0</status>
    <message>The code successfully validated</message>
</sms>
json
{
  "status": 0,
  "message": "The code successfully validated"
}

Errors

StatusWhen
12The code did not verify. The documented meaning of 12 on this operation is Unverified code: wrong code, expired code, or attempts exhausted.
2A 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.