# MTech Pay — интеграция приёма оплат Kaspi (песочница и бой)

Документ самодостаточный: по нему можно подключить проект к MTech Pay без дополнительных вопросов.
Подходит и для разработчика, и для ИИ-агента, который пишет интеграцию.

- Сервис: https://pay.mtech.kz
- Базовый адрес API: `https://pay.mtech.kz/api/v1`
- Документация в браузере: https://pay.mtech.kz/docs
- Поддержка: support@mtech.kz

> MTech Pay — независимый сервис ИП MT TECH. Не является продуктом, партнёром или представителем АО «Kaspi Bank».

---

## 1. Как это работает

1. Ваш **сервер** создаёт платёж запросом `POST /api/v1/payments`.
2. Клиент платит: сканирует QR в приложении Kaspi.kz (или другого банка) либо оплачивает счёт, пришедший на его номер в Kaspi.
3. MTech Pay узнаёт статус у Kaspi и присылает на ваш адрес **вебхук** (`payment.paid`, `payment.cancelled`, …), подписанный секретом.
4. Ваш сервер **проверяет подпись**, **сверяет статус** запросом `GET /api/v1/payments/:id` и только после этого выдаёт товар.

Два режима, полностью изолированных друг от друга:

| | Песочница (test) | Бой (live) |
|---|---|---|
| Ключи | `mtp_test_ci_…` / `mtp_test_cs_…` | `mtp_ci_…` / `mtp_cs_…` |
| Касса Kaspi | не нужна | обязательна |
| Деньги | не списываются | настоящие |
| Кто задаёт статус | вы (симуляция) | Kaspi |
| Вебхук | тестовый адрес + тестовый секрет | боевой адрес + боевой секрет |
| `livemode` в объектах и событиях | `false` | `true` |

Режим определяется **только ключом**: тестовый ключ не видит боевые платежи и наоборот. Код интеграции одинаковый — меняются только ключи и секрет вебхука.

---

## 2. Подготовка в кабинете (делает человек)

1. Зарегистрироваться на https://pay.mtech.kz/register и подтвердить email.
2. **Песочница:** «Интеграция → Песочница»
   - «Создать тестовый ключ» → сохранить `client_id` и `client_secret` (секрет показывается **один раз**);
   - указать тестовый адрес вебхука (`https://…`) → «Создать секрет» → сохранить `whsec_…` (тоже один раз).
3. **Бой** (после проверки в песочнице):
   - «Кассы → Подключить кассу»: в приложении Kaspi Pay → «Сотрудники» добавить сотрудника с ролью **«Кассир»** на **отдельный** номер, ввести номер в кабинете и код из SMS. После подключения **не входить** с этим номером в приложение Kaspi Pay — подключение сбросится;
   - «Интеграция → Ключи API» → «Создать ключ» → сохранить `client_id` / `client_secret`;
   - «Интеграция → Вебхуки» → боевой адрес → «Создать секрет» → сохранить `whsec_…`.

Переменные окружения вашего сервера (никогда не храните их в коде, браузере, мобильном приложении или репозитории):

```env
MTECH_API=https://pay.mtech.kz/api/v1
MTECH_CLIENT_ID=mtp_test_ci_xxxxxxxxxxxx        # на бою: mtp_ci_…
MTECH_CLIENT_SECRET=mtp_test_cs_xxxxxxxxxxxxxxx # на бою: mtp_cs_…
MTECH_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx # тестовый или боевой, по режиму
MTECH_LIVEMODE=false                            # на бою: true
```

---

## 3. Аутентификация

Каждый запрос — с парой `client_id` + `client_secret`, один из способов:

- `Authorization: Basic base64(client_id:client_secret)` (в curl — `-u client_id:client_secret`);
- заголовки `X-Client-Id` и `X-Client-Secret`.

Запросы только с **вашего сервера** по HTTPS. CORS не поддерживается — из браузера API не вызывать. Неверные данные → `401` с заголовком `WWW-Authenticate`.

```bash
curl https://pay.mtech.kz/api/v1/me -u "$MTECH_CLIENT_ID:$MTECH_CLIENT_SECRET"
```

Ответ `GET /me`:

```json
{
  "object": "merchant",
  "organization": { "name": "Моя компания", "created_at": "2026-10-20T05:00:00.000Z" },
  "cashiers": { "active_count": 1, "primary_connected": true },
  "livemode": false,
  "webhook": { "configured": true, "secret_set": true },
  "client_id": "mtp_test_ci_xxxxxxxxxxxx"
}
```

---

## 4. Методы API

Все запросы и ответы — JSON в UTF-8. Для запросов с телом — `Content-Type: application/json`. Суммы — **целые тенге**.

### 4.1. Создать платёж — `POST /payments`

| Поле | Тип | Описание |
|---|---|---|
| `amount` | integer | **Обязательно.** От 1 до 10 000 000 ₸. |
| `kind` | string | `qr` (по умолчанию) — клиент платит по QR; `invoice` — счёт на номер клиента в Kaspi. |
| `phone` | string | **Обязательно для `invoice`.** Номер клиента, например `+7 701 123 45 67`. |
| `description` | string | Назначение платежа, до 200 символов. |
| `external_order_id` | string | Номер заказа в вашей системе, до 64 символов **без пробелов**. Даёт идемпотентность (см. §6). |

Заголовок `Idempotency-Key` (до 64 символов без пробелов) — альтернатива `external_order_id`.

```bash
curl -X POST https://pay.mtech.kz/api/v1/payments \
  -u "$MTECH_CLIENT_ID:$MTECH_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"amount": 12500, "kind": "qr", "description": "Заказ №1042", "external_order_id": "1042"}'
```

Ответ `201` — объект платежа (§5). Повтор с тем же `external_order_id` → `200` и **тот же** платёж; с тем же номером, но другими `amount`/`kind`/`phone` → `409 conflict`.

Что показать клиенту для QR-платежа:
- `payment_page_url` — готовая страница оплаты MTech Pay (сумма, QR, кнопка «Оплатить в Kaspi», автообновление статуса). **Проще всего перенаправить клиента сюда.**
- или сами: `qr_url` — содержимое для QR-кода, `pay_url` — ссылка, открывающая оплату в приложении Kaspi на телефоне.
- QR действует несколько минут — смотрите `expires_at`.

Для `invoice` счёт приходит клиенту в приложение Kaspi; страницы оплаты нет (`payment_page_url: null`).

### 4.2. Получить платёж — `GET /payments/:id`

```bash
curl https://pay.mtech.kz/api/v1/payments/pay_8Hk2xQ1mZp0sTg -u "$MTECH_CLIENT_ID:$MTECH_CLIENT_SECRET"
```

Чужой или несуществующий платёж → `404`. Это **главный источник правды** о статусе.

### 4.3. Список — `GET /payments`

Параметры: `status` (любой из §5), `from`, `to` (`YYYY-MM-DD` по времени Алматы или ISO 8601), `limit` (1–100, по умолчанию 20), `cursor` (значение `next_cursor` из прошлого ответа). Новые сверху.

```json
{ "object": "list", "data": [ { "id": "pay_…", "...": "..." } ], "has_more": true, "next_cursor": "YzEyMw" }
```

### 4.4. Отменить счёт — `POST /payments/:id/cancel`

Только `kind = invoice` и `status = pending`, иначе `409`. Ответ — объект платежа со статусом `cancelled`.

### 4.5. Возврат — `POST /payments/:id/refund`

Только оплаченный QR-платёж (`paid` или `partially_refunded`), иначе `409`. Тело `{"amount": 500}` — частичный возврат; без `amount` — весь остаток. Больше остатка → `400`. Ответ — объект платежа со статусом `refunded` или `partially_refunded` и обновлённым `refunded_amount`.

### 4.6. Симуляция исхода (только песочница) — `POST /sandbox/payments/:id/simulate`

```bash
curl -X POST https://pay.mtech.kz/api/v1/sandbox/payments/pay_xxxx/simulate \
  -u "$MTECH_CLIENT_ID:$MTECH_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"status": "paid"}'
```

`status`: `paid`, `cancelled`, `expired`, `error`. Только тестовым ключом (боевым → `403 livemode_key`) и только для `pending` (иначе `409`). Исход также можно выбрать кнопками на тестовой странице оплаты (`payment_page_url`) или в кабинете. Тестовый платёж сам не истекает, пока исход не выбран. Возврат в песочнице — обычный `POST /payments/:id/refund`.

---

## 5. Объект платежа и статусы

```json
{
  "id": "pay_8Hk2xQ1mZp0sTg",
  "object": "payment",
  "livemode": true,
  "status": "paid",
  "kind": "qr",
  "amount": 12500,
  "refunded_amount": 0,
  "description": "Заказ №1042",
  "external_order_id": "1042",
  "phone": null,
  "qr_url": "https://qr.kaspi.kz/…",
  "pay_url": "https://pay.kaspi.kz/pay/…",
  "payment_page_url": "https://pay.mtech.kz/pay/pay_8Hk2xQ1mZp0sTg",
  "expires_at": "2026-10-20T06:05:00.000Z",
  "paid_at": "2026-10-20T06:01:12.000Z",
  "created_at": "2026-10-20T06:00:00.000Z"
}
```

`phone` в ответах всегда замаскирован. Время — ISO 8601 UTC.

| Статус | Значение | Окончательный |
|---|---|---|
| `pending` | Ждём оплату / Kaspi ещё не ответил | нет |
| `paid` | Оплачено (**только** по подтверждению Kaspi, сумма сверена) | да |
| `cancelled` | Счёт отменён/отклонён, или клиент отказался и код истёк | да |
| `expired` | Срок QR истёк без оплаты | да |
| `error` | Kaspi не провёл оплату | да |
| `refunded` | Полный возврат (только после `paid`) | да |
| `partially_refunded` | Частичный возврат (только после `paid`) | нет — возможен ещё возврат |

Гарантии:
- `paid` ставится только по ответу Kaspi об успешной операции; если Kaspi сообщает сумму — она должна совпасть, иначе `error`.
- Сбои связи с Kaspi или кассы **не меняют** статус: платёж остаётся `pending`, оплата будет засчитана, когда связь восстановится.
- Отказ клиента по QR не окончателен, пока код действует (клиент может отсканировать снова).
- Из окончательного статуса платёж переходит только в `refunded` / `partially_refunded` (после `paid`).
- У каждого платежа своя операция Kaspi: оплата одного клиента не может засчитаться другому платежу.

---

## 6. Идемпотентность

Передавайте `external_order_id` = номер заказа. Повторный запрос (таймаут, ретрай, двойной клик) вернёт **тот же** платёж, а не создаст второй. Номер уникален в пределах вашей организации и режима (тестовый и боевой `1042` — разные платежи). Если номера заказа нет — заголовок `Idempotency-Key`.

---

## 7. Вебхуки

`POST` с JSON на ваш адрес (боевой — для боевых платежей, тестовый — для тестовых).

| Событие | Когда |
|---|---|
| `payment.paid` | Оплачено |
| `payment.cancelled` | Отменён / отклонён |
| `payment.expired` | Истёк |
| `payment.failed` | Оплата не прошла (`status = error`), причина в `failure` |
| `payment.refunded` | Полный или частичный возврат |
| `test.ping` | Тест из кабинета |
| `test.signature_check` | Только песочница: подпись **намеренно неверная** — ответьте `401` |

Заголовки:

| Заголовок | Значение |
|---|---|
| `X-MTech-Event` | тип события |
| `X-MTech-Event-Id` | уникальный id события — по нему отсеивайте повторы |
| `X-MTech-Signature` | HMAC-SHA256 (hex) от **сырого тела** запроса, ключ — `whsec_…` |
| `X-MTech-Livemode` | `true` / `false` |
| `User-Agent` | `MTechPay-Webhook/1` |

Тело:

```json
{
  "object": "event",
  "type": "payment.paid",
  "livemode": true,
  "created_at": "2026-10-20T06:01:13.000Z",
  "data": { "id": "pay_8Hk2xQ1mZp0sTg", "object": "payment", "status": "paid", "amount": 12500, "external_order_id": "1042", "livemode": true, "...": "весь объект платежа (§5)" },
  "failure": { "code": "kaspi_status", "message": "Kaspi не смог провести платёж" }
}
```

`failure` есть только у `payment.failed`. (Плоские поля `id`, `status`, `amount` и др. на верхнем уровне — для совместимости; используйте `data`.)

Доставка:
- Отвечайте `2xx` в течение **10 секунд**; тяжёлую работу — в фоне после ответа.
- Иначе повторы: через 1 мин, 5 мин, 30 мин, 2 ч, 6 ч (всего 6 попыток). Недоставленное событие можно отправить повторно из кабинета.
- События **одного платежа** приходят строго по порядку (`paid` всегда раньше `refunded`).
- Редиректы не выполняются; адрес — только публичный `https://`.
- Событие может прийти повторно — обработка обязана быть идемпотентной.
- Если адрес вебхука не был задан в момент смены статуса, событие не создаётся — статус всё равно доступен через `GET /payments/:id`.

---

## 8. Безопасность — ОБЯЗАТЕЛЬНО

Без этих правил злоумышленник может подделать «оплачено» и получить товар бесплатно.

1. **Проверяйте подпись каждого вебхука**: HMAC-SHA256 от **сырых байтов тела** (до разбора JSON) секретом `whsec_…`, сравнение за постоянное время (`crypto.timingSafeEqual`, `hash_equals`). Неверная или отсутствующая подпись → `401`, заказ не трогать.
2. **Перед выдачей товара сверяйте статус** запросом `GET /payments/:id` со своего сервера: `status === "paid"`.
3. **Сверяйте сумму и номер заказа**: `amount` = сумма заказа в **вашей** базе, `external_order_id` = номер заказа, `id` = платёж, который вы сами создали для этого заказа.
4. **Проверяйте режим**: боевой заказ отмечайте оплаченным только при `livemode === true` (иначе его «оплатят» тестовым платежом).
5. **Не доверяйте браузеру**: никаких отметок «оплачено» по параметрам URL, JavaScript, возврату клиента на сайт, скриншоту чека. Сумму задаёт ваш сервер, а не форма.
6. **Повторы**: храните обработанные `X-MTech-Event-Id`; повторный `payment.paid` не должен второй раз выдавать товар; окончательный статус не перезаписывайте более старым событием.
7. **Ключи только на сервере**, в переменных окружения. Отдельный ключ на каждую систему. Утечка → «Перевыпустить секрет» в кабинете.
8. **HTTPS и проверка сертификата всегда включены**. Запрещено: `CURLOPT_SSL_VERIFYPEER = false`, `rejectUnauthorized: false`, `verify=False`. Не ставьте сервер, который ходит в API, за SSL-инспекцию прокси/межсетевого экрана.
9. Не полагайтесь на IP отправителя вебхука — только на подпись.
10. Не пишите в логи `Authorization`, секреты и подписи.

---

## 9. Ошибки

Формат: `{"error": {"code": "…", "message": "…", "field": "…"}}` (`field` — если ошибка в конкретном поле).

| HTTP | code | Что делать |
|---|---|---|
| 400 | `validation_error` | Исправить поле из `field` |
| 400 | `invalid_json` | Тело — валидный JSON-объект |
| 401 | `unauthorized` | Проверить ключ и режим |
| 403 | `livemode_key` | Симуляция — только тестовым ключом |
| 404 | `not_found` | Нет такого платежа в вашей организации и режиме |
| 409 | `conflict` | Повтор с другими параметрами / недопустимое действие для статуса |
| 413 | `payload_too_large` | Тело больше 64 КБ |
| 415 | `unsupported_media_type` | Нужен `Content-Type: application/json` |
| 422 | `cassa_not_connected` | Подключить кассу в кабинете (только бой) |
| 422 | `payment_failed` | Kaspi не принял операцию; причина в `message`. Можно повторить позже |
| 429 | `rate_limited` | Подождать `Retry-After` секунд |
| 5xx | — | Повторить с тем же `external_order_id` (идемпотентно) |

## 10. Лимиты

- 1200 запросов в минуту на ключ API.
- 600 новых платежей в минуту на организацию.
- 2000 платежей в сутки через одну кассу Kaspi (больше — подключите несколько касс).
- Тело запроса — до 64 КБ.
- Нужно больше — support@mtech.kz.

---

## 11. Эталонная реализация

### Node.js (18+, без зависимостей)

```js
// mtech.js — клиент API
const API = process.env.MTECH_API || 'https://pay.mtech.kz/api/v1';
const AUTH = 'Basic ' + Buffer.from(`${process.env.MTECH_CLIENT_ID}:${process.env.MTECH_CLIENT_SECRET}`).toString('base64');

async function mtech(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: AUTH, ...(body ? { 'Content-Type': 'application/json' } : {}) },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(15000),
  });
  const json = await res.json();
  if (!res.ok) throw Object.assign(new Error(json.error?.message || `HTTP ${res.status}`), { status: res.status, code: json.error?.code });
  return json;
}

// Создать платёж для заказа: сумма — из ВАШЕЙ базы, номер заказа — для идемпотентности.
export async function createPayment(order) {
  const p = await mtech('POST', '/payments', {
    amount: order.amount,
    kind: 'qr',
    description: `Заказ №${order.id}`,
    external_order_id: String(order.id),
  });
  await db.savePaymentId(order.id, p.id); // запомнить связь заказ ↔ платёж
  return p.payment_page_url;              // перенаправить клиента сюда
}

export const getPayment = (id) => mtech('GET', `/payments/${encodeURIComponent(id)}`);
```

```js
// webhook.js — обработчик вебхука
import crypto from 'node:crypto';
import http from 'node:http';
import { getPayment } from './mtech.js';

const SECRET = process.env.MTECH_WEBHOOK_SECRET;
const LIVE = process.env.MTECH_LIVEMODE === 'true';

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));
};

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(); } // 1
    const eventId = String(req.headers['x-mtech-event-id'] || '');
    if (await db.isEventProcessed(eventId)) { res.statusCode = 200; return res.end('dup'); }               // 6
    res.statusCode = 200; res.end('ok');                                                                   // ответ сразу

    const event = JSON.parse(raw.toString('utf8'));
    if (event.type.startsWith('test.')) return db.saveProcessedEvent(eventId);
    const order = await db.findOrderByPaymentId(event.data.id);                                            // 3
    if (!order) return db.saveProcessedEvent(eventId);
    const p = await getPayment(event.data.id);                                                             // 2: правда — из API
    const ok = p.livemode === LIVE && p.amount === order.amount && p.external_order_id === String(order.id); // 3, 4
    if (ok && p.status === 'paid') await db.markOrderPaid(order.id, p.id);                                // идемпотентно
    else if (ok && ['cancelled', 'expired', 'error'].includes(p.status)) await db.markOrderUnpaid(order.id, p.status);
    else if (ok && (p.status === 'refunded' || p.status === 'partially_refunded')) await db.markOrderRefunded(order.id, p.refunded_amount);
    await db.saveProcessedEvent(eventId);
  });
}).listen(3000);
```

### PHP (7.4+)

```php
<?php
// mtech.php — клиент API
function mtech(string $method, string $path, ?array $body = null): array {
    $ch = curl_init(getenv('MTECH_API') . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_USERPWD        => getenv('MTECH_CLIENT_ID') . ':' . getenv('MTECH_CLIENT_SECRET'),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => $body ? ['Content-Type: application/json'] : [],
        CURLOPT_POSTFIELDS     => $body ? json_encode($body, JSON_UNESCAPED_UNICODE) : null,
        // проверка сертификата включена по умолчанию — НЕ отключать
    ]);
    $json = json_decode(curl_exec($ch), true);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($code >= 400) throw new RuntimeException($json['error']['message'] ?? "HTTP $code", $code);
    return $json;
}

function create_payment(array $order): string {
    $p = mtech('POST', '/payments', [
        'amount' => (int)$order['amount'],
        'kind' => 'qr',
        'description' => 'Заказ №' . $order['id'],
        'external_order_id' => (string)$order['id'],
    ]);
    save_payment_id($order['id'], $p['id']);
    return $p['payment_page_url']; // header('Location: ' . ...)
}
```

```php
<?php
// webhook.php — обработчик вебхука
require 'mtech.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$raw = file_get_contents('php://input', false, null, 0, 65536);

$expected = hash_hmac('sha256', $raw, getenv('MTECH_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_MTECH_SIGNATURE'] ?? '')) { http_response_code(401); exit; }

$eventId = $_SERVER['HTTP_X_MTECH_EVENT_ID'] ?? '';
if (event_processed($eventId)) { http_response_code(200); exit('dup'); }

$event = json_decode($raw, true);
if (strpos($event['type'], 'test.') !== 0) {
    $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);
    if ($order) {
        $p = mtech('GET', '/payments/' . $id);                    // правда — из API
        $live = getenv('MTECH_LIVEMODE') === 'true';
        $ok = $p['livemode'] === $live
            && (int)$p['amount'] === (int)$order['amount']
            && $p['external_order_id'] === (string)$order['id'];
        if ($ok && $p['status'] === 'paid') mark_order_paid($order['id'], $id);
        elseif ($ok && in_array($p['status'], ['cancelled', 'expired', 'error'], true)) mark_order_unpaid($order['id'], $p['status']);
        elseif ($ok && in_array($p['status'], ['refunded', 'partially_refunded'], true)) mark_order_refunded($order['id'], $p['refunded_amount']);
    }
}
save_processed_event($eventId);
http_response_code(200);
echo 'ok';
```

---

## 12. Проверка в песочнице (обязательно до боя)

1. Ключи — тестовые (`mtp_test_…`), `MTECH_LIVEMODE=false`, тестовый `whsec_…`.
2. Создать платёж → открыть `payment_page_url` → нажать «Оплатить (тест)» (или `POST /sandbox/payments/:id/simulate` с `paid`) → ваш заказ стал оплаченным.
3. Прогнать остальные исходы: `cancelled`, `expired`, `error` → заказ **не** оплачен; затем возврат `POST /payments/:id/refund` по оплаченному.
4. Проверить повтор: сохраните сырое тело и заголовки полученного `payment.paid` и отправьте их на свой обработчик ещё раз (например, curl'ом) → товар не выдаётся второй раз.
5. «Интеграция → Песочница → Проверить защиту»: мы шлём событие с поддельной подписью — ваш сервер обязан ответить `401`.
6. Чек-лист «Готовность к запуску» в кабинете — все пункты ✓.

## 13. Переход на бой

1. Подключить кассу Kaspi (§2).
2. Создать боевой ключ, указать боевой адрес вебхука и секрет.
3. Заменить переменные окружения: `MTECH_CLIENT_ID`, `MTECH_CLIENT_SECRET`, `MTECH_WEBHOOK_SECRET` на боевые, `MTECH_LIVEMODE=true`. Код не меняется.
4. Сделать один реальный платёж на небольшую сумму и вернуть его.

## 14. Чек-лист перед запуском

- [ ] Подпись вебхука проверяется по сырому телу; неверная → 401, заказ не меняется.
- [ ] Перед выдачей товара — `GET /payments/:id` и `status === "paid"`.
- [ ] Сверяются `amount`, `external_order_id`, `livemode` и связь заказ ↔ `id` платежа.
- [ ] Повторы отсеиваются по `X-MTech-Event-Id`, обработка идемпотентна.
- [ ] Платёж создаётся с `external_order_id` (повтор запроса не создаёт второй платёж).
- [ ] Ключи и секреты — только в окружении сервера.
- [ ] TLS-проверка включена; вебхук только по HTTPS; ответ ≤ 10 с.
- [ ] Заказ не отмечается оплаченным по данным из браузера.
- [ ] В песочнице пройдены все исходы и «Проверить защиту».

## 15. Частые ошибки

| Симптом | Причина |
|---|---|
| Подпись не совпадает | Считаете от пересобранного JSON (`JSON.stringify(req.body)`), а нужно от сырых байтов |
| `401` на все запросы | Тестовый ключ к боевому платежу или наоборот; пробелы/переносы в секрете |
| `404` на свой платёж | Платёж создан в другом режиме (test/live) |
| `422 cassa_not_connected` | На бою не подключена касса или она отключилась (письмо «Касса отключилась») |
| Вебхуки не приходят | Не задан адрес/секрет для этого режима; адрес не публичный или не `https`; сервер отвечает дольше 10 с |
| Ошибка сертификата при запросах к API | Сервер за SSL-инспекцией (например, FortiGate) — исключите `pay.mtech.kz` из инспекции; проверку не отключать |
| Дубль заказа после ретрая | Не передан `external_order_id` |
