---
url: https://textme-docs.matat.io/endpoints/otp.md
description: >-
  send_otp and validate_otp: send a one-time code by SMS and verify it, with
  TextMe generating, storing, counting attempts against and expiring the code.
---

# 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`](#send-otp-send-a-one-time-code) issues a code, [`validate_otp`](#validate-otp-check-a-one-time-code) verifies one.

::: tip Where this comes from
These two operations are documented by TextMe at [docs.textme.co.il/otp/](https://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](../reference/status-codes.md#account-state) 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](./verified-senders.md). | ✔️ |
| `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

::: code-group

```xml [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 [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]
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 [JavaScript]
// 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]
<?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 [Laravel]
<?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 [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 [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]
// 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 [C#]
// .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 [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 [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

::: code-group

```xml [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 [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`.

::: danger 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

::: code-group

```xml [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 [JSON]
{
  "validate_otp": {
    "user": {
      "username": "Leeroy"
    },
    "phone": "5xxxxxxxx",
    "app_id": "1",
    "code": "407526",
    "service_type": "sms"
  }
}
```

```bash [cURL]
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 [JavaScript]
// 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]
<?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 [Laravel]
<?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 [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 [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]
// 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 [C#]
// .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 [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 [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

::: code-group

```xml [XML]
<?xml version="1.0" encoding="utf-8"?>
<sms>
    <status>0</status>
    <message>The code successfully validated</message>
</sms>
```

```json [JSON]
{
  "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. |

::: danger 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](../reference/status-codes.md#account-state).
:::

## 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`](./send.md#parameters) 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`](./send.md) and do its own verification.

### What this replaces

Without these two operations you would send a code with [`sms`](./send.md), 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.
