Как встроить отправку SMS в свой проект через REST API
Задача звучит одинаково в любом проекте: при каком-то событии надо отправить человеку SMS. Заказ оформлен, запись подтверждена, код для входа сгенерирован. Дальше всё сводится к одному HTTP-запросу, и сложность прячется не в нём, а в четырёх вещах вокруг.
Ниже общая схема, без привязки к конкретной CRM или учётной системе. Подойдёт всему, что умеет сделать POST-запрос: своему бэкенду, скрипту по расписанию, сценарию автоматизации, самописной админке.
Что нужно до кода
Android-телефон с SIM-картой и установленным приложением, привязанный к аккаунту. Он и будет отправлять сообщения. Ваш код обращается не к нему напрямую, а к облаку, которое ставит задачу в очередь и передаёт её аппарату.
Минимальный запрос
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-ключ?
У нас пока нет отдельного долгоживущего ключа: авторизация идёт Bearer-токеном, который выдаётся при входе в личный кабинет. Для постоянно работающего сервиса это значит, что нужно закладывать повторное получение токена, а не хранить один и тот же вечно.
Что произойдёт, если телефон-шлюз окажется офлайн?
Сообщение не потеряется: оно встанет в очередь на сервере и уйдёт, когда устройство вернётся в сеть. Именно поэтому ответ API означает «принято к отправке», а не «доставлено абоненту».
Как избежать дублей при повторной отправке?
Держите у себя признак «для этого события SMS уже отправлена» и проверяйте его до запроса. Повтор по таймауту опасен тем, что первый запрос мог дойти, а ответ потеряться, и абонент получит два одинаковых сообщения.
Можно ли проверить интеграцию, не написав ни строчки кода?
Да. В личном кабинете есть песочница: запрос собирается в браузере, отправляется по-настоящему и показывает ответ. Удобно, чтобы отделить проблему в своём коде от проблемы в интеграции.