Документация API
Платежи и вебхуки MTech Pay
Начало работы
API помогает создавать платежи из вашего сайта или учётной системы и узнавать, когда клиент заплатил. Все запросы и ответы — JSON в кодировке UTF-8, суммы — целые тенге.
Базовый адрес: https://pay.mtech.kz/api/v1
Вся инструкция одним файлом (Markdown) — удобно отдать другому проекту или ИИ-агенту: https://pay.mtech.kz/docs/integration.md
- В разделе «Интеграция» создайте ключ API — вы получите
client_idиclient_secret. - Подключите кассу Kaspi в разделе «Кассы» — без неё платежи создать нельзя.
- Отправьте первый запрос с Basic-авторизацией (ниже).
Для запросов с телом передавайте заголовок Content-Type: application/json. API работает только по HTTPS-адресу сайта и не отдаёт CORS-заголовков: вызывайте его с вашего сервера, а не из браузера.
Аутентификация
Каждый запрос подписывается парой client_id + client_secret. Есть два равнозначных способа:
Authorization: Basic base64(client_id:client_secret)— в curl это ключ-u;- заголовки
X-Client-IdиX-Client-Secret.
curl https://pay.mtech.kz/api/v1/me \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# то же самое отдельными заголовками
curl https://pay.mtech.kz/api/v1/me \
-H "X-Client-Id: mtp_ci_xxxxxxxxxxxx" \
-H "X-Client-Secret: mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
client_secret показывается один раз при создании. Храните его только на сервере — в переменных окружения, а не в коде сайта или мобильного приложения. Если секрет утёк — в кабинете нажмите «Перевыпустить секрет» (старый перестанет работать сразу) или отзовите интеграцию.
Неверные данные дают ответ 401 с заголовком WWW-Authenticate: Basic realm="MTech Pay API". Cookie кабинета API не принимает.
Безопасность интеграции — обязательно
1. Проверяйте подпись каждого вебхука
- Считайте
HMAC-SHA256от сырого тела запроса (байты как пришли, до разбора JSON) секретомwhsec_…и сравнивайте с заголовкомX-MTech-Signatureфункцией сравнения за постоянное время (crypto.timingSafeEqual,hash_equals). - Нет подписи или она не совпала — отвечайте
401и ничего не меняйте в заказе. - Не пересобирайте JSON перед проверкой: изменится порядок полей или пробелы — подпись не совпадёт, а «упрощённая» проверка откроет дыру.
2. Перед выдачей товара сверяйте статус через API
- Получив
payment.paid, запроситеGET /api/v1/payments/:idсо своего сервера и выдавайте товар, только если там"status": "paid". - Сверьте
amountс суммой заказа иexternal_order_idс номером заказа. Не совпало — не выдавайте товар и разберитесь вручную. - Проверьте, что
data.id— платёж, который создали вы для этого заказа: сохраняйте id платежа при создании и ищите заказ по нему.
3. Не доверяйте браузеру и клиенту
- Никогда не отмечайте заказ оплаченным по параметрам в адресе страницы, по ответу JavaScript, по возврату клиента на сайт или по скриншоту чека. Источник правды — только вебхук с верной подписью и ответ API, полученные вашим сервером.
- Сумму платежа задаёт ваш сервер из своей базы, а не поле формы, которое прислал покупатель.
4. Защита от повторов
- Сохраняйте
X-MTech-Event-Idобработанных событий и не обрабатывайте одно событие дважды. - Обработка должна быть идемпотентной: повторный
payment.paidне должен второй раз выдавать товар или начислять бонусы. - Окончательные статусы (
paid,cancelled,expired,error) не перезаписывайте более старыми событиями; при сомнении — сверка через API.
5. Храните ключи только на сервере
client_secretи секрет вебхука — только в переменных окружения или хранилище секретов сервера. Не кладите их в код сайта, мобильное приложение, JavaScript в браузере, публичный репозиторий, логи и письма.- Отдельный ключ для каждой системы: при утечке вы отзовёте один ключ, не задев остальные.
- Подозрение на утечку — сразу «Перевыпустить секрет» или «Сменить секрет» в разделе «Интеграция».
6. Защищённый канал
- Обращайтесь к API только по
https://pay.mtech.kzи не отключайте проверку TLS-сертификата (CURLOPT_SSL_VERIFYPEER = false,rejectUnauthorized: false,verify=False— запрещено): иначе ответ можно подменить по пути. - Адрес вебхука — только
https://с действующим сертификатом. Редиректы на другой адрес мы не выполняем. - Не полагайтесь на IP-адрес отправителя — полагайтесь на подпись.
7. Обработчик вебхука
- Принимайте только
POST, ограничьте размер тела (64 КБ достаточно), отвечайте2xxв течение 10 секунд — тяжёлую работу делайте в фоне после ответа. - Не пишите в лог заголовок
Authorization, секреты и подпись. Пишите id события и id платежа.
8. Проверяйте режим: поле livemode
- В каждом платеже и событии есть поле
livemode(и заголовокX-MTech-Livemode). Боевой заказ отмечайте оплаченным только по событию сlivemode = true— иначе тестовым платежом из песочницы можно было бы «оплатить» настоящий заказ. - Для боевого и тестового режима используйте разные секреты вебхука, разные ключи и разные адреса — не смешивайте их в одной базе заказов.
- Сверяйте
livemodeи в ответеGET /api/v1/payments/:id:"livemode": trueнужен для выдачи товара.
Полный пример обработчика
import crypto from 'node:crypto';
import http from 'node:http';
const SECRET = process.env.MTECH_WEBHOOK_SECRET; // whsec_… — только из окружения сервера
const CLIENT_ID = process.env.MTECH_CLIENT_ID; // mtp_ci_…
const CLIENT_SECRET = process.env.MTECH_CLIENT_SECRET; // mtp_cs_…
const API = 'https://pay.mtech.kz/api/v1';
// 1. Подпись: HMAC-SHA256 от СЫРОГО тела, сравнение за постоянное время.
const validSignature = (raw, header) => {
const expected = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
const got = String(header || '');
return got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
};
// 2. Сверка: статус и сумму берём из API (HTTPS, проверка сертификата включена по умолчанию).
const fetchPayment = async (id) => {
if (!/^pay_[A-Za-z0-9_-]{8,40}$/.test(id)) throw new Error('bad id');
const r = await fetch(API + '/payments/' + id, {
headers: { Authorization: 'Basic ' + Buffer.from(CLIENT_ID + ':' + CLIENT_SECRET).toString('base64') },
});
if (!r.ok) throw new Error('api ' + r.status);
return r.json();
};
http.createServer((req, res) => {
if (req.method !== 'POST') { res.statusCode = 405; return res.end(); }
const chunks = []; let size = 0;
req.on('data', (c) => { size += c.length; if (size > 64 * 1024) req.destroy(); else chunks.push(c); });
req.on('end', async () => {
const raw = Buffer.concat(chunks);
if (!validSignature(raw, req.headers['x-mtech-signature'])) { res.statusCode = 401; return res.end(); }
const eventId = String(req.headers['x-mtech-event-id'] || '');
if (await db.isEventProcessed(eventId)) { res.statusCode = 200; return res.end('dup'); } // 3. повтор
const event = JSON.parse(raw.toString('utf8'));
if (event.type === 'payment.paid') {
const order = await db.findOrderByPaymentId(event.data.id); // 4. платёж должен быть ВАШИМ
const payment = await fetchPayment(event.data.id); // 5. подтверждение из API
if (order && payment.status === 'paid'
&& payment.livemode === true && event.livemode === true // боевой заказ — только боевой платёж
&& payment.amount === order.amount // 6. сумма совпадает
&& payment.external_order_id === order.id) {
await db.markOrderPaid(order.id, payment.id); // идемпотентно
}
}
await db.saveProcessedEvent(eventId);
res.statusCode = 200;
res.end('ok');
});
}).listen(3000);
<?php
$secret = getenv('MTECH_WEBHOOK_SECRET'); // whsec_…
$auth = getenv('MTECH_CLIENT_ID') . ':' . getenv('MTECH_CLIENT_SECRET');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$raw = file_get_contents('php://input', false, null, 0, 65536); // сырое тело
// 1. Подпись
$expected = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_MTECH_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
// 3. Повтор
$eventId = $_SERVER['HTTP_X_MTECH_EVENT_ID'] ?? '';
if (event_processed($eventId)) { http_response_code(200); exit('dup'); }
$event = json_decode($raw, true);
if (($event['type'] ?? '') === 'payment.paid') {
$id = $event['data']['id'] ?? '';
if (!preg_match('/^pay_[A-Za-z0-9_-]{8,40}$/', $id)) { http_response_code(400); exit; }
$order = find_order_by_payment_id($id); // 4. платёж ваш
// 5. Подтверждение из API. Проверку сертификата НЕ отключать.
$ch = curl_init('https://pay.mtech.kz/api/v1/payments/' . $id);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_USERPWD => $auth, CURLOPT_TIMEOUT => 10]);
$payment = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($order && $code === 200 && ($payment['status'] ?? '') === 'paid'
&& ($payment['livemode'] ?? false) === true // боевой заказ — только боевой платёж
&& (int)$payment['amount'] === (int)$order['amount'] // 6. сумма
&& ($payment['external_order_id'] ?? '') === (string)$order['id']) {
mark_order_paid($order['id'], $id); // идемпотентно
}
}
save_processed_event($eventId);
http_response_code(200);
echo 'ok';
Чек-лист перед запуском
- Подпись вебхука проверяется по сырому телу; неверная — 401 и без изменений в заказе.
- Перед выдачей товара статус сверяется через
GET /api/v1/payments/:id. - Сумма и
external_order_idсверяются с заказом в вашей базе. - Повторные события отсеиваются по
X-MTech-Event-Id, обработка идемпотентна. - Ключи и секреты — только в окружении сервера, не в браузере и не в репозитории.
- Проверка TLS-сертификата включена, вебхук принимается только по HTTPS.
- Заказ не отмечается оплаченным по данным из браузера.
- Боевой заказ отмечается оплаченным только при
livemode = true.
Песочница (тестовый режим)
Песочница позволяет настроить и проверить интеграцию без кассы Kaspi и без денег. Поведение то же, что в бою: те же объекты, статусы, переходы, события и порядок их доставки. Отличие одно — исход платежа задаёте вы, а не Kaspi.
Тестовые ключи
- В кабинете: «Интеграция» → «Песочница» → «Создать тестовый ключ». Формат:
client_id = mtp_test_ci_…,client_secret = mtp_test_cs_…. Боевые ключи —mtp_ci_…/mtp_cs_…. - Режим запроса определяется только ключом. Тестовый ключ видит и меняет лишь тестовые платежи, боевой — лишь боевые. Тестовый платёж боевым ключом (и наоборот) вернёт
404. - Один и тот же
external_order_idможно использовать и в тесте, и в бою — это два разных платежа. - Во всех объектах есть поле
livemode:falseу тестовых,trueу боевых.
Отличия от боя
- Касса не нужна, Kaspi не вызывается, дневной лимит кассы не считается (лимит частоты запросов остаётся).
- QR-платёж:
qr_urlиpay_urlведут на тестовую страницу оплаты/pay/pay_…, а не в Kaspi. Счёт на номер создаётся так же, телефон проверяется как в бою. - Платёж остаётся
pending, пока вы не выберете исход (см. ниже). Сам он не истекает. - Возврат тестового платежа и отмена тестового счёта выполняются сразу, без Kaspi, с теми же статусами и событиями.
curl -X POST https://pay.mtech.kz/api/v1/payments \
-u mtp_test_ci_xxxxxxxxxxxx:mtp_test_cs_xxxxxxxxxxxxxxxxxxxxxxxx \
-H "Content-Type: application/json" \
-d '{"amount": 1500, "kind": "qr", "external_order_id": "test-1"}'
Симуляция исхода
/api/v1/sandbox/payments/:id/simulate
Переводит тестовый платёж из pending в paid, cancelled, expired или error — тем же кодом, что и боевые переходы, и отправляет событие на тестовый вебхук.
curl -X POST https://pay.mtech.kz/api/v1/sandbox/payments/pay_8Hk2xQ1mZp0sTg/simulate \
-u mtp_test_ci_xxxxxxxxxxxx:mtp_test_cs_xxxxxxxxxxxxxxxxxxxxxxxx \
-H "Content-Type: application/json" \
-d '{"status": "paid"}'
- Тело:
{"status": "paid" | "cancelled" | "expired" | "error"}. Успех —200и обновлённый платёж. - Боевой ключ →
403 livemode_key. Платёж не в статусеpending→409 conflict. Боевой платёж изменить так нельзя ни через API, ни через страницу оплаты, ни из кабинета. - То же можно сделать руками: на странице
payment_page_urlтестового платежа и на странице платежа в кабинете есть кнопки «Оплатить (тест)», «Отменить (тест)», «Ошибка оплаты (тест)», «Истёк (тест)».
Тестовый вебхук
- Адрес и секрет тестового вебхука задаются отдельно (вкладка «Песочница»). Тестовые события подписаны тестовым секретом и приходят только на тестовый адрес; боевые — только на боевой.
- Формат, подпись, повторы и порядок доставки те же, что в разделе «Вебхуки». Добавлены: заголовок
X-MTech-Livemode: falseи полеlivemode(в событии и вdata). - Если тестовый адрес не задан, тестовые события не создаются.
- В боевом режиме адрес — только
https://и публичный; то же требование и к тестовому.
Проверка готовности
На вкладке «Песочница» есть чек-лист «Готовность к запуску». Он отмечает автоматически:
- создан тестовый платёж через API;
- статус запрошен через API (
GET /api/v1/payments/:idтестовым ключом); - тестовый вебхук доставлен с ответом 2xx;
- сервер проверяет подпись — кнопка «Проверить защиту» отправляет событие
test.signature_checkс неверной подписью. Ваш сервер должен ответить4xx(обычно 401). Ответ2xxозначает, что подделку приняли — исправьте проверку подписи; - обработаны все виды событий:
payment.paid,payment.cancelled(илиpayment.expired) иpayment.refunded.
Переход на бой
- Создайте боевой ключ в разделе «Ключи API» и замените им тестовый в своей программе.
- Подключите кассу Kaspi.
- Укажите боевой адрес вебхука и создайте боевой секрет.
Тестовые и боевые данные не пересекаются: платежи, ключи, события, секреты и адреса вебхуков у каждого режима свои. Боевой заказ отмечайте оплаченным только при livemode = true.
Создание платежа
/api/v1/payments
Создаёт платёж и возвращает его. Успех — код 201.
| Поле | Описание |
|---|---|
amount | Обязательное. Целое число тенге от 1 до 10 000 000. |
kind | «qr» (по умолчанию) — клиент платит по QR-коду; «invoice» — счёт на номер клиента в Kaspi. |
phone | Обязательное для invoice. Номер клиента, например «+7 701 123 45 67». |
description | Назначение платежа, до 200 символов. |
external_order_id | Номер заказа в вашей системе, до 64 символов без пробелов. По нему работает идемпотентность. |
curl -X POST https://pay.mtech.kz/api/v1/payments \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-H "Content-Type: application/json" \
-d '{
"amount": 12500,
"kind": "qr",
"description": "Заказ №1042",
"external_order_id": "order-1042"
}'
{
"id": "pay_8Hk2xQ1mZp0sTg",
"object": "payment",
"livemode": true,
"status": "pending",
"kind": "qr",
"amount": 12500,
"refunded_amount": 0,
"description": "Заказ №1042",
"external_order_id": "order-1042",
"phone": null,
"qr_url": "https://qr.kaspi.kz/…",
"pay_url": "https://pay.kaspi.kz/pay/…",
"payment_page_url": null,
"expires_at": "2026-01-15T10:05:00.000Z",
"paid_at": null,
"created_at": "2026-01-15T10:00:00.000Z"
}
Покажите клиенту QR-код из qr_url (это содержимое для QR) либо отправьте ему payment_page_url — готовую страницу оплаты с QR-кодом, суммой и таймером (она сама обновится, когда клиент заплатит). Также есть pay_url — прямая ссылка для оплаты в приложении Kaspi. Код действует несколько минут, поле expires_at показывает точное время.
curl -X POST https://pay.mtech.kz/api/v1/payments \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-H "Content-Type: application/json" \
-d '{"amount": 4900, "kind": "invoice", "phone": "+7 701 123 45 67", "description": "Абонемент"}'
Счёт придёт клиенту в приложении Kaspi. В ответе номер телефона всегда замаскирован.
Статус и список
/api/v1/payments/:id
Возвращает платёж по его id. Чужие и несуществующие платежи — 404.
curl https://pay.mtech.kz/api/v1/payments/pay_8Hk2xQ1mZp0sTg \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Статус меняется сам, когда клиент платит. Надёжнее всего получать уведомления через вебхук, а запросы статуса использовать как запасной путь — не чаще раза в несколько секунд.
Как определяется статус
paidставится только по подтверждению Kaspi об успешной операции (и если Kaspi сообщает сумму — она должна совпасть). Догадок и таймеров здесь нет.- Пока Kaspi не дал окончательного ответа, платёж остаётся
pending— даже если связь с Kaspi временно пропала или касса отключилась. Мы продолжаем спрашивать, и оплата, прошедшая в это время, будет засчитана. - Отмена клиентом и неуспешная попытка по QR не окончательны, пока код действует: клиент может отсканировать его снова. Статус
cancelled/errorфиксируется после истечения кода. expired— Kaspi сообщил, что код истёк, либо Kaspi не дал ответа в течение 30 минут после истечения кода.paid,cancelled,expired,error— окончательные: из них платёж может перейти только вrefunded/partially_refunded(послеpaid).
Рекомендация: перед выдачей товара или услуги сверяйте статус запросом GET /api/v1/payments/:id — это гарантирует, что вы видите актуальное состояние, даже если вебхук задержался.
/api/v1/payments
Список платежей, новые сверху.
| Параметр | Описание |
|---|---|
status | pending, paid, cancelled, expired, error, refunded, partially_refunded. |
from, to | Границы по дате создания: «2026-01-31» (по времени Алматы) или момент в формате ISO 8601. |
limit | От 1 до 100, по умолчанию 20. |
cursor | Значение next_cursor из предыдущего ответа. |
curl "https://pay.mtech.kz/api/v1/payments?status=paid&from=2026-01-01&limit=50" \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ответ
{
"object": "list",
"data": [ { "id": "pay_…", "object": "payment", … } ],
"has_more": true,
"next_cursor": "YzEyMw"
}
# следующая страница
curl "https://pay.mtech.kz/api/v1/payments?status=paid&from=2026-01-01&limit=50&cursor=YzEyMw" \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Отмена и возврат
/api/v1/payments/:id/cancel
Отменяет неоплаченный счёт (kind = invoice, status = pending). QR-коды отменять не нужно — они истекают сами.
curl -X POST https://pay.mtech.kz/api/v1/payments/pay_8Hk2xQ1mZp0sTg/cancel \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
/api/v1/payments/:id/refund
Возвращает деньги за оплаченный QR-платёж. Тело необязательно: amount — сумма возврата, по умолчанию весь остаток.
# вернуть часть суммы
curl -X POST https://pay.mtech.kz/api/v1/payments/pay_8Hk2xQ1mZp0sTg/refund \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-H "Content-Type: application/json" \
-d '{"amount": 2000}'
# без тела — вернуть весь остаток
curl -X POST https://pay.mtech.kz/api/v1/payments/pay_8Hk2xQ1mZp0sTg/refund \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Если платёж в другом состоянии — ответ 409 conflict. Сумма больше остатка — 400 validation_error.
Организация
/api/v1/me
Данные организации: название, число активных касс, подключена ли основная касса, настроен ли вебхук (боевой или тестовый — по режиму ключа) и поле livemode.
Идемпотентность
Сеть бывает ненадёжной: запрос могут повторить, не зная, дошёл ли он. Чтобы не создать два платежа за один заказ, передайте external_order_id в теле или заголовок Idempotency-Key (до 64 символов).
- Первый запрос создаёт платёж и отвечает
201. - Повтор с тем же значением возвращает тот же платёж с кодом
200. - Если повтор отличается суммой или типом —
409 conflict.
curl -X POST https://pay.mtech.kz/api/v1/payments \
-u mtp_ci_xxxxxxxxxxxx:mtp_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2b9e-order-1042" \
-d '{"amount": 12500}'
Ошибки
Ошибка всегда выглядит так, а код ответа показывает тип проблемы:
{
"error": {
"code": "validation_error",
"message": "Поле amount — целое число тенге от 1 до 10000000",
"field": "amount"
}
}
| Код | Когда |
|---|---|
400 validation_error | Неверное поле; имя поля — в «field». |
401 unauthorized | Нет или неверны client_id / client_secret, интеграция отозвана. |
404 not_found | Платёж не найден или принадлежит другой организации. |
409 conflict | Действие невозможно в текущем состоянии платежа, или конфликт идемпотентности. |
422 cassa_not_connected | Нет подключённой кассы Kaspi. |
422 payment_failed | Kaspi не принял операцию; причина — в «message». |
429 rate_limited | Слишком много запросов; смотрите заголовок Retry-After. |
500 internal_error | Ошибка на нашей стороне. Повторите запрос с тем же external_order_id. |
Лимиты
- До 1200 запросов в минуту на один ключ API.
- До 600 новых платежей в минуту на организацию.
- До 2000 платежей в сутки через одну кассу Kaspi — чтобы не вызвать блокировку кассира на стороне Kaspi. Для большего объёма подключите несколько касс.
- Нужно больше — напишите на support@mtech.kz, поднимем лимиты для вашей организации.
- Размер тела запроса — до 64 КБ.
При превышении приходит 429 и заголовок Retry-After (секунды).
Вебхуки
Когда платёж меняет статус, мы отправляем POST с JSON на адрес, указанный в кабинете. Настройте адрес и секрет в разделе «Интеграция» и проверьте связь кнопкой «Отправить тестовое событие».
| Событие | Когда |
|---|---|
payment.paid | Платёж оплачен. |
payment.cancelled | Счёт отменён. |
payment.expired | Срок платежа истёк. |
payment.refunded | Выполнен полный или частичный возврат. |
payment.failed | Оплата не прошла (status = error). Причина — в поле failure: {code, message}. |
test.ping | Тестовое событие из кабинета. |
test.signature_check | Только в песочнице: проверка защиты, подпись намеренно неверная. Ответьте 401. |
POST /mtech-webhook HTTP/1.1
Content-Type: application/json
User-Agent: MTechPay-Webhook/1
X-MTech-Event: payment.paid
X-MTech-Livemode: true
X-MTech-Signature: 3b6a…c9f1
{
"object": "event",
"type": "payment.paid",
"livemode": true,
"created_at": "2026-01-15T10:02:11.000Z",
"data": {
"id": "pay_8Hk2xQ1mZp0sTg",
"object": "payment",
"livemode": true,
"status": "paid",
"amount": 12500,
…
}
}
Заголовки: X-MTech-Event — тип события, X-MTech-Event-Id — уникальный номер события (по нему удобно отсеивать повторы), X-MTech-Signature — подпись.
Проверка подписи
Подпись — это HMAC-SHA256 от сырого тела запроса с ключом-секретом вебхука (whsec_…) в шестнадцатеричном виде. Считайте её по байтам, как они пришли, и сравнивайте за постоянное время. Запрос с неверной подписью отклоняйте.
import crypto from 'node:crypto';
import http from 'node:http';
const SECRET = process.env.MTECH_WEBHOOK_SECRET; // whsec_…
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks); // СЫРОЕ тело, не пересобранный JSON
const expected = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
const got = String(req.headers['x-mtech-signature'] || '');
const ok =
got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) {
res.statusCode = 401;
return res.end();
}
const event = JSON.parse(body.toString('utf8'));
if (event.type === 'payment.paid') {
// отметьте заказ event.data.external_order_id оплаченным (повторы возможны — делайте это идемпотентно)
}
res.statusCode = 200;
res.end('ok');
});
}).listen(3000);
<?php
$secret = getenv('MTECH_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input'); // сырое тело запроса
$got = $_SERVER['HTTP_X_MTECH_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $got)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
if (($event['type'] ?? '') === 'payment.paid') {
// отметьте заказ $event['data']['external_order_id'] оплаченным (делайте это идемпотентно)
}
http_response_code(200);
echo 'ok';
Доставка и повторы
Отвечайте кодом 2xx в течение 10 секунд. Иначе мы повторим отправку до 6 раз с нарастающими паузами (1 минута, 5 минут, 30 минут, 2 часа, 6 часов). Событие может прийти повторно — обрабатывайте его идемпотентно по X-MTech-Event-Id. События одного платежа доставляются строго по порядку: следующее не уйдёт, пока предыдущее не доставлено или не исчерпало попытки. Окончательный статус берите из data.status, а при сомнениях сверяйте запросом статуса. Журнал доставок виден в кабинете; событие, которое не удалось доставить за все попытки, можно отправить повторно кнопкой «Отправить повторно».
Адрес вебхука должен быть публичным: запросы во внутреннюю сеть блокируются.
Объект платежа
| Поле | Описание |
|---|---|
id | Идентификатор вида pay_…. |
livemode | true — боевой платёж, false — тестовый (песочница). |
status | pending — ждём оплату; paid — оплачен; cancelled — отменён; expired — истёк; error — ошибка; refunded — возвращён полностью; partially_refunded — возвращён частично. |
kind | qr или invoice. |
amount, refunded_amount | Сумма и возвращённая сумма в тенге. |
description, external_order_id | Ваши данные, переданные при создании. |
phone | Маска номера клиента (для счетов), иначе null. |
qr_url, pay_url | Для QR-платежа: содержимое QR-кода и ссылка для оплаты. Иначе null. |
payment_page_url | Адрес страницы оплаты для клиента (QR, сумма, таймер, статус) — откройте его клиенту или встройте в свой сайт. Для счетов на номер — null. |
expires_at, paid_at, created_at | Время в формате ISO 8601 (UTC) или null. |