ГлавнаяПереадресация SMS → Отправка SMS из своего проекта

Как встроить отправку SMS в свой проект через REST API

Задача звучит одинаково в любом проекте: при каком-то событии надо отправить человеку SMS. Заказ оформлен, запись подтверждена, код для входа сгенерирован. Дальше всё сводится к одному HTTP-запросу, и сложность прячется не в нём, а в четырёх вещах вокруг.

Ниже общая схема, без привязки к конкретной CRM или учётной системе. Подойдёт всему, что умеет сделать POST-запрос: своему бэкенду, скрипту по расписанию, сценарию автоматизации, самописной админке.

Что нужно до кода

Android-телефон с SIM-картой и установленным приложением, привязанный к аккаунту. Он и будет отправлять сообщения. Ваш код обращается не к нему напрямую, а к облаку, которое ставит задачу в очередь и передаёт её аппарату.

Ваш проект POST-запрос HTTPS API и очередь api.textbee.ru Ваш телефон со своей SIM Абонент если телефон офлайн, задача ждёт в очереди и уходит, когда он вернётся в сеть
Ответ API означает «принято к отправке». Между ним и сообщением в руках абонента стоят очередь, телефон и сеть оператора.

Минимальный запрос

cURL

curl https://api.textbee.ru/v1/sms/send \
  -H "Authorization: Bearer ВАШ_ТОКЕН" \
  -H "Content-Type: application/json" \
  -d '{"phoneNumber": "+79161234567", "text": "Ваш код: 12345"}'

Это весь протокол. Дальше то же самое на любом языке, разница только в синтаксисе.

Python

import requests

r = requests.post(
    "https://api.textbee.ru/v1/sms/send",
    headers={"Authorization": f"Bearer {token}"},
    json={"phoneNumber": "+79161234567", "text": "Ваш код: 12345"},
    timeout=15,
)
r.raise_for_status()
message_id = r.json().get("id")

Node.js

const res = await fetch("https://api.textbee.ru/v1/sms/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ phoneNumber: "+79161234567", text: "Ваш код: 12345" }),
});
if (!res.ok) throw new Error(`SMS API: ${res.status}`);
const { id } = await res.json();

В ответе приходит идентификатор сообщения. По нему потом можно спросить статус доставки через GET /v1/messages/{id}.

Четыре вещи, которые ломаются в проде

1. Токен живёт не вечно

Самая частая ошибка при интеграции, и у нас она особенно заметна. Bearer-токен выдаётся при входе в личный кабинет, то есть это сессионный токен, а не отдельный долгоживущий API-ключ. Если положить его в переменную окружения и забыть, однажды сервис начнёт получать 401.

Закладывайте повторное получение токена сразу, а не после первого инцидента. Практично так: обёртка вокруг отправки ловит 401, обновляет токен, повторяет запрос один раз.

Готовое решение: обёртка с обновлением токена

Токен получается запросом POST /v1/auth/login с телом {"email": ..., "password": ...}. Обёртка ниже хранит токен в памяти, а на ответ 401 получает новый и повторяет отправку ровно один раз. Больше одного повтора не нужно: если и второй раз 401, дело не в сроке жизни токена.

Python

import threading
import requests

API = "https://api.textbee.ru"


class TextBee:
    """Отправка SMS с автоматическим обновлением токена."""

    def __init__(self, email, password, timeout=15):
        self._email = email
        self._password = password
        self._timeout = timeout
        self._token = None
        self._lock = threading.Lock()

    def _login(self):
        r = requests.post(
            f"{API}/v1/auth/login",
            json={"email": self._email, "password": self._password},
            timeout=self._timeout,
        )
        r.raise_for_status()
        data = r.json()
        # имя поля зависит от версии API, берём первое непустое
        token = (data.get("token")
                 or data.get("accessToken")
                 or data.get("access_token"))
        if not token:
            raise RuntimeError(f"в ответе логина нет токена: {list(data)}")
        return token

    def _get_token(self, force=False):
        with self._lock:
            if force or not self._token:
                self._token = self._login()
            return self._token

    def send(self, phone_number, text):
        for attempt in (1, 2):
            token = self._get_token(force=(attempt == 2))
            r = requests.post(
                f"{API}/v1/sms/send",
                headers={"Authorization": f"Bearer {token}"},
                json={"phoneNumber": phone_number, "text": text},
                timeout=self._timeout,
            )
            if r.status_code == 401 and attempt == 1:
                continue          # токен протух, обновим и попробуем ещё раз
            r.raise_for_status()
            return r.json()


client = TextBee("you@example.com", "пароль")
print(client.send("+79161234567", "Ваш код: 12345"))

Node.js

const API = "https://api.textbee.ru";

class TextBee {
  constructor(email, password, timeoutMs = 15000) {
    this.email = email;
    this.password = password;
    this.timeoutMs = timeoutMs;
    this.token = null;
    this.pending = null;          // чтобы параллельные вызовы не логинились хором
  }

  async login() {
    if (this.pending) return this.pending;
    this.pending = (async () => {
      const res = await fetch(`${API}/v1/auth/login`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: this.email, password: this.password }),
        signal: AbortSignal.timeout(this.timeoutMs),
      });
      if (!res.ok) throw new Error(`login: ${res.status}`);
      const data = await res.json();
      const token =
        data.token ?? data.accessToken ?? data.access_token;
      if (!token) throw new Error("в ответе логина нет токена");
      this.token = token;
      return token;
    })().finally(() => { this.pending = null; });
    return this.pending;
  }

  async send(phoneNumber, text) {
    for (const attempt of [1, 2]) {
      const token = (attempt === 2 || !this.token)
        ? await this.login()
        : this.token;
      const res = await fetch(`${API}/v1/sms/send`, {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ phoneNumber, text }),
        signal: AbortSignal.timeout(this.timeoutMs),
      });
      if (res.status === 401 && attempt === 1) continue;
      if (!res.ok) throw new Error(`sms/send: ${res.status}`);
      return res.json();
    }
  }
}

Блокировка в Python и pending в Node нужны для одного и того же: когда токен протух, запросов на отправку обычно идёт несколько сразу, и без этого все они пойдут логиниться одновременно.

2. Повтор по таймауту рождает дубли

Классика распределённых систем. Запрос ушёл, ответ не вернулся, код повторяет отправку. Но первый запрос вполне мог дойти, и абонент получает два одинаковых сообщения с кодом. Для кодов подтверждения это не просто некрасиво, это ещё и путает человека: какой из двух вводить.

Решение простое и на вашей стороне. Заведите у себя признак «для этого события SMS уже отправлена» и проверяйте его перед запросом, а не после. Ключом берите то, что уникально по смыслу: идентификатор заказа, попытки входа, записи на приём.

Готовое решение: защита от дублей по ключу события

Ключевая мысль в том, когда писать в базу. Отметку ставим до отправки, а не после: пропущенная SMS лечится повтором вручную, а две одинаковые с разными кодами уже не лечатся никак.

SQL

CREATE TABLE sms_outbox (
    event_key   TEXT PRIMARY KEY,          -- order:1234:paid, login:99:otp
    status      TEXT NOT NULL,             -- pending | sent | failed
    message_id  TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ
);

Python

def send_once(conn, client, event_key, phone_number, text):
    """Отправляет SMS не более одного раза на event_key."""
    with conn:
        cur = conn.execute(
            "INSERT INTO sms_outbox (event_key, status) "
            "VALUES (%s, 'pending') "
            "ON CONFLICT (event_key) DO NOTHING RETURNING event_key",
            (event_key,),
        )
        if cur.fetchone() is None:
            return None                    # уже отправляли или отправляем

    try:
        result = client.send(phone_number, text)
    except Exception:
        # ВАЖНО: на таймауте статус НЕ трогаем и запись НЕ удаляем.
        # Запрос мог дойти, и повтор отправит второе сообщение.
        # Такие записи разбираем отдельно, сверяя статус по message_id.
        raise

    with conn:
        conn.execute(
            "UPDATE sms_outbox SET status='sent', message_id=%s, "
            "updated_at=now() "
            "WHERE event_key=%s",
            (result.get("id"), event_key),
        )
    return result

ON CONFLICT DO NOTHING делает проверку и вставку одной операцией. Если разнести их на «сначала SELECT, потом INSERT», два параллельных обработчика успеют проскочить оба, и вы получите ровно тот дубль, от которого защищались.

3. Ответ API это не доставка

Успешный ответ означает, что задача принята. Дальше она может ждать в очереди, если телефон офлайн, и уйти через полчаса. Или уйти сразу, но не дойти, потому что у абонента выключен аппарат.

Поэтому не показывайте пользователю «SMS отправлена» по коду 200, если от этого зависит его следующий шаг. Честнее «код отправлен, придёт в течение минуты» и кнопка повтора, которая станет активной не сразу.

Готовое решение: проверка доставки по идентификатору

Ответ на отправку даёт идентификатор, по нему статус спрашивается через GET /v1/messages/{id}. Опрашивать имеет смысл с паузой и с потолком по времени: если за пару минут статус не стал конечным, дело не в скорости, а в телефоне или сети.

Python

import time

TERMINAL = {"delivered", "failed", "undelivered", "expired"}


def wait_delivery(client, message_id, timeout=180, interval=5):
    """Ждёт конечного статуса. Возвращает статус или None, если не дождались."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        token = client._get_token()
        r = requests.get(
            f"{API}/v1/messages/{message_id}",
            headers={"Authorization": f"Bearer {token}"},
            timeout=15,
        )
        r.raise_for_status()
        status = r.json().get("status")
        if status in TERMINAL:
            return status
        time.sleep(interval)
    return None                    # не дождались, но сообщение могло уйти позже

Набор конечных статусов сверьте с тем, что реально приходит в вашем аккаунте: список выше покрывает обычные случаи, но полагаться на него вслепую не стоит. Всё, чего нет в наборе, код считает промежуточным и продолжает ждать, а это безопаснее обратного.

И не блокируйте на этом пользовательский запрос. Ожидание доставки это фоновая задача: в момент нажатия кнопки честный ответ звучит как «код отправлен», а не «код доставлен».

4. Лимиты тарифа и темп

У бесплатного тарифа темп ограничен, у платных нет. Сообщения сверх темпа не отбрасываются, а встают в очередь, но если ваш код ждёт мгновенной отправки в цикле, поведение вас удивит. Отправку в цикле по списку лучше вообще не писать: см. следующий раздел.

Готовое решение: повторы при 429 и 5xx

Здесь есть тонкость, из-за которой наивный повтор опаснее отказа. На 429 сообщение точно не принято, повторять безопасно. На 5xx и на таймауте оно могло быть принято, и повтор рискует дублем. Поэтому повторяем автоматически только первое, а остальное отдаём защите по ключу события из решения выше.

Python

import random
import time

import requests


def send_with_retry(client, phone_number, text, attempts=4):
    delay = 1.0
    last = None
    for i in range(attempts):
        try:
            return client.send(phone_number, text)
        except requests.HTTPError as e:
            code = e.response.status_code
            last = e
            if code == 429:
                # уважаем Retry-After, если сервер его прислал
                wait = float(e.response.headers.get("Retry-After", delay))
            elif 500 <= code < 600:
                raise                      # могло быть принято, повтор опасен
            else:
                raise                      # 4xx: повторять бессмысленно
            time.sleep(wait + random.uniform(0, 0.3))   # джиттер против пачки
            delay = min(delay * 2, 60)
    raise last


# в цикле по списку получателей отправку писать не надо:
# см. раздел про рассылки ниже

Случайная добавка к паузе нужна, когда отправку делает несколько процессов сразу: без неё они упрутся в лимит, подождут одинаковое время и упрутся снова, уже хором.

Чего делать не надо: рассылок по базе номеров. Сообщения уходят с обычной пользовательской SIM, а договоры операторов ограничивают либо запрещают её использование для массовой автоматической отправки. Оператор вправе приостановить обслуживание номера. Область, где всё это уместно, другая: коды подтверждения, уведомления о заказе и доставке, служебные сообщения своим клиентам. Если задача именно в рекламе по базе, вам нужен агрегатор, и мы говорим это прямо.

Как проверить, не написав кода

В личном кабинете есть песочница: запрос собирается в браузере, отправляется по-настоящему и показывает ответ целиком. Это удобно в момент, когда непонятно, чья проблема. Если в песочнице сообщение уходит, а из вашего кода нет, дело в коде, и круг поиска сузился вдвое.

Что стоит залогировать у себя

Минимум, который окупается на первом же разборе инцидента: время запроса, идентификатор сообщения из ответа, код ответа и ваш собственный ключ события. Без последнего вы не свяжете сообщение с заказом, а именно этот вопрос и задаёт поддержка через неделю.

Текст сообщения у себя логировать не обязательно, а иногда и вредно: в нём коды и персональные данные. Мы у себя его не храним, полей для него в базе нет, и вам чаще всего достаточно шаблона и параметров.

Свой SMS-шлюз и REST API

Бесплатный тариф, чтобы попробовать. Без договора и согласования имени отправителя.

Попробовать бесплатно   Документация API →

Частые вопросы

Нужен ли отдельный API-ключ?

У нас пока нет отдельного долгоживущего ключа: авторизация идёт Bearer-токеном, который выдаётся при входе в личный кабинет. Для постоянно работающего сервиса это значит, что нужно закладывать повторное получение токена, а не хранить один и тот же вечно.

Что произойдёт, если телефон-шлюз окажется офлайн?

Сообщение не потеряется: оно встанет в очередь на сервере и уйдёт, когда устройство вернётся в сеть. Именно поэтому ответ API означает «принято к отправке», а не «доставлено абоненту».

Как избежать дублей при повторной отправке?

Держите у себя признак «для этого события SMS уже отправлена» и проверяйте его до запроса. Повтор по таймауту опасен тем, что первый запрос мог дойти, а ответ потеряться, и абонент получит два одинаковых сообщения.

Можно ли проверить интеграцию, не написав ни строчки кода?

Да. В личном кабинете есть песочница: запрос собирается в браузере, отправляется по-настоящему и показывает ответ. Удобно, чтобы отделить проблему в своём коде от проблемы в интеграции.