SMS-платежи до App Store: как работала мобильная коммерция в Казнете в 2012 году
В 2012 году мы работали над проектом мобильных платежей для одного из алматинских ретейлеров. Задача: дать пользователям возможность оплатить заказ с телефона. Звучит просто. Реальность была совсем другой.
App Store существовал — iPhone 5 уже вышел. Но доля смартфонов среди пользователей нашего клиента составляла 22%. Остальные 78% — кнопочные телефоны с поддержкой WAP, USSD и SMS. Делать только iOS-решение означало игнорировать большинство аудитории.
Три реальных платёжных канала в Казнете 2012
1. Премиум-SMS через контент-провайдеров
Пользователь отправлял SMS на короткий номер (например, 7171), в ответ получал код подтверждения, вводил его на сайте. Оператор списывал сумму с мобильного счёта и передавал её контент-провайдеру за вычетом комиссии (25–40%).
Пользователь → SMS "PAY 1500" на 7171
Оператор → проверяет баланс пользователя
Оператор → списывает 1500 тенге с баланса
Оператор → HTTP POST на callback URL контент-провайдера:
{"msisdn":"77012345678","amount":1500,"ref":"TX123456"}
Контент-провайдер → HTTP POST на наш webhook:
{"transaction_id":"TX123456","amount":1035,"status":"success"}
# 1035 = 1500 минус 31% комиссия
Наш webhook на PHP:
// sms_callback.php
$transactionId = $_POST['transaction_id'];
$amount = (int) $_POST['amount'];
$status = $_POST['status'];
$hmac = $_POST['hmac'];
// Проверка подписи запроса
$expected = hash_hmac('sha1', $transactionId . $amount . $status, PROVIDER_SECRET);
if (!hash_equals($expected, $hmac)) {
http_response_code(403);
exit('Invalid signature');
}
if ($status === 'success') {
$order = Order::findByTransactionRef($transactionId);
$order->markPaid($amount);
// Отправить SMS с подтверждением пользователю
SmsGateway::send($order->phone, "Оплата {$amount} тенге принята. Заказ #{$order->id}");
}
http_response_code(200);
echo 'OK';
Главная проблема: комиссия 25–40%. Для товаров с низкой маржой это было неприемлемо. SMS-платежи работали для цифровых товаров (контент, игровая валюта) и не работали для физических.
2. USSD-сессии
USSD (*100# и аналоги) — это интерактивное меню прямо в телефоне, без интернета. Оператор открывает USSD-сессию и передаёт ввод пользователя на наш сервер через специальный протокол.
Пользователь набирает: *250*ORDER_ID#
Оператор открывает USSD-сессию, POST на наш сервер:
{
"session_id": "sess_abc123",
"msisdn": "77012345678",
"input": "ORDER_ID",
"type": "BEGIN"
}
Наш сервер отвечает:
{
"session_id": "sess_abc123",
"message": "Заказ #1234 на сумму 8500 тенге.\n1. Оплатить\n2. Отмена",
"type": "CON" // CON = продолжить сессию, END = завершить
}
Пользователь нажимает "1":
{
"session_id": "sess_abc123",
"input": "1",
"type": "CON"
}
Наш сервер:
{
"message": "Подтвердите оплату 8500 тенге.\n1. Да\n2. Нет",
"type": "CON"
}
USSD-сессия — синхронная и короткая (таймаут 3–5 минут). Вся логика должна быть stateful: хранить состояние сессии между запросами.
class UssdSession {
private $redis;
public function handle(string $sessionId, string $msisdn, string $input, string $type): array {
$state = $this->redis->get("ussd:{$sessionId}")
? json_decode($this->redis->get("ussd:{$sessionId}"), true)
: ['step' => 'init'];
switch ($state['step']) {
case 'init':
$orderId = trim($input);
$order = Order::findById($orderId);
if (!$order || $order->phone !== $msisdn) {
return $this->end("Заказ не найден.");
}
$this->saveState($sessionId, ['step' => 'confirm', 'order_id' => $orderId]);
return $this->cont("Заказ #{$orderId}: {$order->amount} тг.\n1. Оплатить\n2. Отмена");
case 'confirm':
if ($input === '1') {
$this->processPayment($state['order_id'], $msisdn);
return $this->end("Оплата принята. Спасибо.");
}
return $this->end("Отменено.");
}
}
private function saveState(string $sessionId, array $state): void {
$this->redis->setex("ussd:{$sessionId}", 300, json_encode($state));
}
}
USSD работал на любом телефоне с SIM-картой, не требовал интернета и имел комиссию 5–10% — значительно лучше, чем SMS.
3. WAP-сайт для смартфонов
Для смартфонов без нормального браузера (Symbian, ранний Android с маленькими экранами) делали отдельный WAP-вариант с минимальным HTML:
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.0//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<style>
/* Максимум 5KB CSS — WAP-браузеры не любят больше */
body { font-size: 14px; margin: 10px; }
.btn { display: block; padding: 12px; background: #0070c0; color: #fff; text-align: center; }
</style>
</head>
<body>
<p>Заказ #<?= $order->id ?></p>
<p>Сумма: <?= $order->amount ?> тг.</p>
<a class="btn" href="/wap/pay?order=<?= $order->id ?>&token=<?= $token ?>">Оплатить</a>
</body>
</html>
WAP-сайт переадресовывал на страницу оплаты Казкома (они имели WAP-версию процессинга) и получал callback после подтверждения.
Архитектура, которая получилась
В итоге у нас была единая платёжная абстракция, которая выбирала канал в зависимости от устройства:
class PaymentRouter {
public function route(Order $order, string $userAgent): PaymentChannel {
if ($this->isSmartphone($userAgent)) {
return new KaskomWebChannel($order); // Полноценная веб-страница
}
if ($this->isMidRange($userAgent)) {
return new WapChannel($order); // WAP-страница
}
// Кнопочный телефон
return new UssdChannel($order); // USSD-меню
}
}
Что изменилось к 2013
К концу 2012 — началу 2013 ситуация начала быстро меняться: проникновение смартфонов росло, Kazkom и Халык выпустили нормальные мобильные SDK. USSD и WAP из основных каналов превратились в резервные.
Но эти полтора года работы с SMS, USSD и WAP научили нас главному: платёжная система должна проектироваться под реальную аудиторию, а не под устройство разработчика. В 2012 году реальная аудитория Казахстана сидела на кнопочных телефонах. Игнорировать её означало потерять рынок.
Тот же принцип мы применяем сегодня при выборе между Kaspi Pay, Apple Pay и банковскими картами — смотрим на статистику платежей реальных пользователей, а не на то, что удобнее интегрировать.