Сценарии

Коды подтверждения (OTP) в WhatsApp вместо SMS

Обновлено 6 мин чтения

Код подтверждения при регистрации, входе или смене телефона обычно отправляют по SMS. WhatsApp — удобная альтернатива: пользователь видит код в привычном приложении, а вы платите за номер, а не за каждое сообщение. Но у схемы есть честные ограничения, и о них лучше знать до запуска.

Ниже — как устроена отправка кодов через wapi.kz, где она подходит, где нет, и пример сервера на Node.js с хешированием и лимитами.

Как это устроено

wapi.kz только доставляет сообщение. Код генерирует и проверяет ваш сервер — так безопаснее и проще.

Шаг 1. Сгенерируйте код

6 цифр из криптографически стойкого генератора — в Node.js это crypto.randomInt, в Python — модуль secrets. Не используйте Math.random и не делайте код из времени или номера телефона.

Шаг 2. Сохраните хеш и срок жизни

Храните не сам код, а его хеш с секретом (HMAC), время истечения — 5 минут — и счётчик попыток. Утечка базы тогда не выдаст действующие коды.

Шаг 3. Проверьте номер и отправьте

Перед отправкой спросите POST /contacts/check, есть ли у номера WhatsApp. Есть — отправляйте через POST /messages/send. Нет или отправка не прошла — уходите на резервный канал, обычно SMS.

Шаг 4. Проверьте введённый код

Посчитайте хеш введённого кода и сравните с сохранённым за постоянное время — в Node.js это crypto.timingSafeEqual. После 5 неверных попыток код аннулируйте, после успешной проверки — удаляйте: код одноразовый.

Шаг 5. Ограничьте частоту запросов

Не больше одного кода в минуту и трёх за 15 минут на номер, не больше десятка в час с одного IP. Без этого форма входа превращается в инструмент для «SMS-бомбинга» — о нём ниже.

Текст сообщения

482913 — код для входа в «Ваш сервис». Никому его не сообщайте. Код действует 5 минут.

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

Плюсы и честные минусы

Плюсы:

  • Цена. Номер стоит 9 900 ₸ в месяц, сообщения не тарифицируются. Например, 3 000 кодов в месяц стоят те же 9 900 ₸ (цены).
  • Доставка видна. Вебхук message.ack скажет, доставлено и прочитано ли сообщение.
  • Привычно. Пользователь получает код там же, где переписывается каждый день.

Минусы, о которых молчать нельзя:

  • WhatsApp есть не у всех, а телефон пользователя может быть не в сети. Резервный канал — SMS — нужен всегда.
  • Почти каждый код — первое сообщение новому человеку. Такие сообщения ограничены: в первые 3 дня после подключения номера — 20 новых контактов в сутки, на уровне «Хороший» — 150, и не чаще одного раза в 5 секунд (лимиты).
  • Коды выглядят как рассылка. Если один и тот же текст за час ушёл 25+ новым людям или за час появилось 40+ новых контактов, первые сообщения новым людям приостанавливаются на 24 часа. Коды различаются только цифрами, и защита считает такие тексты одинаковыми — пик регистраций после рекламы упрётся в неё быстро.
  • На коды не отвечают. Индекс доверия учитывает, отвечают ли люди на ваши первые сообщения и кто начинает диалоги. Поток кодов незнакомым людям его снижает, а вместе с ним падают лимиты.
  • Риск блокировки. Большой поток сообщений незнакомым людям сильнее всего повышает риск блокировки номера. Гарантий «без блокировок» нет ни у кого.
  • Коды видны на телефоне номера и хранятся в журнале сообщений без срока. Хранение текстов можно выключить в настройках аккаунта — оно выключится для всех сообщений.

Вывод: коды в WhatsApp через подключённый номер подходят для небольшого потока — десятки новых пользователей в день плюс повторные входы тех, кому вы уже писали. Для больших объёмов нужен официальный WhatsApp Business API с шаблоном категории «аутентификация» — раскидывать коды по нескольким номерам ради обхода лимитов не стоит, это лишь умножает риск блокировки: дольше подключение, Meta берёт плату за каждое доставленное шаблонное сообщение, зато при соблюдении правил риск блокировки ниже. Сравнение — в гайде официальный или неофициальный API.

Обратная схема: пользователь пишет первым

Если поток растёт, переверните схему. Сайт показывает короткий код и ссылку вида https://wa.me/77010000000?text=482913 — по ней открывается WhatsApp с готовым текстом на ваш номер. Пользователь нажимает «отправить», ваш вебхук получает событие message с номером отправителя и текстом, сервер сверяет код и подтверждает этот телефон.

Диалог начинает сам пользователь, поэтому лимит новых контактов не расходуется, а индекс доверия не страдает. Минус — лишний шаг для пользователя. Если в событии нет телефона, например номер скрыт, отправьте такого пользователя на обычную схему.

Пример на Node.js

Express-сервер с двумя адресами: /otp/send и /otp/verify. Коды и счётчики здесь в памяти; в продакшене храните их в Redis или базе.

JavaScript
// otp.mjs — npm i express; OTP_SECRET и WAPI_KEY — в переменных окружения; node otp.mjs
import express from "express";
import crypto from "node:crypto";

const app = express();
app.use(express.json());
app.set("trust proxy", 1);                        // за прокси: req.ip — адрес клиента

const WAPI = "https://wapi.kz/api/v1";
const TTL = 5 * 60_000, MAX_ATTEMPTS = 5;
const codes = new Map();                          // телефон → { hash, expires, attempts }
const hits = new Map();                           // ключ лимита → время запросов

const hmac = (phone, code) => crypto.createHmac("sha256", process.env.OTP_SECRET).update(`${phone}:${code}`).digest();
const digits = (v) => String(v ?? "").replace(/\D/g, "");

function limited(key, max, windowMs) {
  const now = Date.now();
  const list = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
  list.push(now);
  hits.set(key, list);
  return list.length > max;
}

async function wapi(path, body) {
  const r = await fetch(WAPI + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.WAPI_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(15_000),
  });
  return { status: r.status, data: await r.json().catch(() => ({})) };
}

async function sendSms(phone, text) {
  throw new Error("Подключите резервный SMS-канал");   // здесь — ваш SMS-провайдер
}

app.post("/otp/send", async (req, res) => {
  const phone = digits(req.body?.phone);
  if (!/^7\d{10}$/.test(phone)) return res.status(400).json({ error: "Номер в формате 7XXXXXXXXXX" });
  if (limited(`ip:${req.ip}`, 10, 3_600_000) || limited(`phone:${phone}`, 3, 900_000) || limited(`gap:${phone}`, 1, 60_000)) {
    return res.status(429).json({ error: "Слишком много запросов, попробуйте позже" });
  }
  const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
  codes.set(phone, { hash: hmac(phone, code), expires: Date.now() + TTL, attempts: 0 });
  const text = `${code} — код для входа в «Ваш сервис». Никому его не сообщайте. Код действует 5 минут.`;
  try {
    const check = await wapi("/contacts/check", { phone });
    if (check.data.exists) {
      const sent = await wapi("/messages/send", { to: phone, text });
      if (sent.status === 200) return res.json({ ok: true, channel: "whatsapp" });
      console.warn("wapi:", sent.data.code);      // код ошибки, не OTP: new_contacts_limit, spam_detected…
    }
    await sendSms(phone, text);
    res.json({ ok: true, channel: "sms" });
  } catch {
    res.status(503).json({ error: "Не удалось отправить код, попробуйте позже" });
  }
});

app.post("/otp/verify", (req, res) => {
  const phone = digits(req.body?.phone);
  const code = String(req.body?.code ?? "");
  const rec = codes.get(phone);
  if (!rec || rec.expires < Date.now()) {
    codes.delete(phone);
    return res.status(400).json({ error: "Код устарел, запросите новый" });
  }
  if (++rec.attempts > MAX_ATTEMPTS) {
    codes.delete(phone);
    return res.status(429).json({ error: "Слишком много попыток, запросите новый код" });
  }
  if (!/^\d{6}$/.test(code) || !crypto.timingSafeEqual(rec.hash, hmac(phone, code))) {
    return res.status(400).json({ error: "Неверный код" });
  }
  codes.delete(phone);                            // код одноразовый
  res.json({ ok: true });                         // дальше — ваша сессия или токен
});

app.listen(3000);

Защита от перебора и SMS-бомбинга

Перебор. У 6-значного кода миллион вариантов. Пять попыток на код и не больше трёх кодов за 15 минут на номер делают угадывание практически безнадёжным. Новый код заменяет старый — подбирать его заново придётся с нуля.

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

Гигиена. Не пишите коды в логи, держите OTP_SECRET и API-ключ на сервере — никогда в браузере или мобильном приложении. Кто получит ключ, сможет писать с вашего номера. Если ключ утёк, перевыпустите его в кабинете.

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

wapi.kz сам генерирует и проверяет коды?

Нет. Сервис доставляет сообщение, а код создаёт и проверяет ваш сервер — как в примере выше. Так код не покидает вашу систему иначе как в тексте сообщения.

Сколько стоит отправка кода?

Отдельной платы за сообщения нет: 9 900 ₸ в месяц за номер, оплата с баланса по дням. Ограничивают не деньги, а лимиты номера на первые сообщения новым людям.

Что, если у пользователя нет WhatsApp?

/contacts/check вернёт exists: false — отправляйте код по SMS. Резервный канал нужен в любом случае: телефон пользователя может быть не в сети.

Не заблокируют ли номер из-за кодов?

Риск есть, и он растёт с потоком кодов незнакомым людям. Лимиты сервиса его снижают, но не убирают. Для больших объёмов честнее официальный API с шаблоном аутентификации — подробнее в гайде как снизить риск блокировки.

Как попробовать?

Зарегистрируйтесь, подключите номер по QR-коду и отправьте код себе. Бесплатного теста нет, но пополнение начинается от 1 000 ₸, а номер списывается по дням — 330 ₸ в сутки.

Подключите WhatsApp к своим системам

9 900 ₸ в месяц за номер, без платы за сообщения. Сервер в Казахстане, оплата Kaspi или счётом на ТОО/ИП. Подключение — около 5 минут.