Начало работы

API помогает создавать платежи из вашего сайта или учётной системы и узнавать, когда клиент заплатил. Все запросы и ответы — JSON в кодировке UTF-8, суммы — целые тенге.

Базовый адрес: https://pay.mtech.kz/api/v1

Вся инструкция одним файлом (Markdown) — удобно отдать другому проекту или ИИ-агенту: https://pay.mtech.kz/docs/integration.md

  1. В разделе «Интеграция» создайте ключ API — вы получите client_id и client_secret.
  2. Подключите кассу Kaspi в разделе «Кассы» — без неё платежи создать нельзя.
  3. Отправьте первый запрос с Basic-авторизацией (ниже).
Обязательно выполните правила из раздела «Безопасность интеграции» до запуска: без проверки подписи и сверки статуса через API ответ можно подделать на вашей стороне.

Для запросов с телом передавайте заголовок 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 нужен для выдачи товара.

Полный пример обработчика

Node.js: подпись → повтор → платёж ваш → сверка через API → сумма
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: то же самое
<?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';

Чек-лист перед запуском

  1. Подпись вебхука проверяется по сырому телу; неверная — 401 и без изменений в заказе.
  2. Перед выдачей товара статус сверяется через GET /api/v1/payments/:id.
  3. Сумма и external_order_id сверяются с заказом в вашей базе.
  4. Повторные события отсеиваются по X-MTech-Event-Id, обработка идемпотентна.
  5. Ключи и секреты — только в окружении сервера, не в браузере и не в репозитории.
  6. Проверка TLS-сертификата включена, вебхук принимается только по HTTPS.
  7. Заказ не отмечается оплаченным по данным из браузера.
  8. Боевой заказ отмечается оплаченным только при 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"}'

Симуляция исхода

POST/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:// и публичный; то же требование и к тестовому.

Проверка готовности

На вкладке «Песочница» есть чек-лист «Готовность к запуску». Он отмечает автоматически:

  1. создан тестовый платёж через API;
  2. статус запрошен через API (GET /api/v1/payments/:id тестовым ключом);
  3. тестовый вебхук доставлен с ответом 2xx;
  4. сервер проверяет подпись — кнопка «Проверить защиту» отправляет событие test.signature_check с неверной подписью. Ваш сервер должен ответить 4xx (обычно 401). Ответ 2xx означает, что подделку приняли — исправьте проверку подписи;
  5. обработаны все виды событий: payment.paid, payment.cancelled (или payment.expired) и payment.refunded.

Переход на бой

  1. Создайте боевой ключ в разделе «Ключи API» и замените им тестовый в своей программе.
  2. Подключите кассу Kaspi.
  3. Укажите боевой адрес вебхука и создайте боевой секрет.

Тестовые и боевые данные не пересекаются: платежи, ключи, события, секреты и адреса вебхуков у каждого режима свои. Боевой заказ отмечайте оплаченным только при livemode = true.

Создание платежа

POST/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 символов без пробелов. По нему работает идемпотентность.
QR-платёж
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"
  }'
Ответ 201
{
  "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. В ответе номер телефона всегда замаскирован.

Статус и список

GET/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 — это гарантирует, что вы видите актуальное состояние, даже если вебхук задержался.

GET/api/v1/payments

Список платежей, новые сверху.

ПараметрОписание
statuspending, 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

Отмена и возврат

POST/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
POST/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.

Идемпотентность

Сеть бывает ненадёжной: запрос могут повторить, не зная, дошёл ли он. Чтобы не создать два платежа за один заказ, передайте 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_failedKaspi не принял операцию; причина — в «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_…) в шестнадцатеричном виде. Считайте её по байтам, как они пришли, и сравнивайте за постоянное время. Запрос с неверной подписью отклоняйте.

Node.js
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
<?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_….
livemodetrue — боевой платёж, false — тестовый (песочница).
statuspending — ждём оплату; paid — оплачен; cancelled — отменён; expired — истёк; error — ошибка; refunded — возвращён полностью; partially_refunded — возвращён частично.
kindqr или 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.