# Тестирование вебхуков ApiPay локально через ngrok

> Получайте webhook-уведомления ApiPay на localhost за 3 минуты. Без деплоя, без сервера.

## Зачем

При разработке у вас нет публичного URL — ApiPay не может отправить webhook на `localhost:3000`. **ngrok** создаёт HTTPS-тоннель к вашему компьютеру и даёт публичный URL.

> Тестируйте в песочнице и на отдельном API-ключе. Webhook URL привязан к ключу: если подставить туннель рабочему ключу, в туннель пойдут уведомления о настоящих оплатах, а Inspect UI покажет их тело целиком. Рабочий ключ на туннель переключать не нужно.

## Без туннеля: `apipay-cli listen` (только песочница)

Если обработчик работает на вашем компьютере, а организация в песочнице, туннель не обязателен. Команда ApiPay сама забирает уведомления песочницы и передаёт их обработчику на `localhost` — с тем же телом и той же подписью `X-Webhook-Signature`, что и обычная доставка. Нужен Node.js 18+ и файл `.env` в корне проекта с `APIPAY_API_KEY` ключа песочной организации; обработчику для проверки подписи нужен `APIPAY_WEBHOOK_SECRET` того же ключа. Оба значения кладёт `apipay-cli` по ссылке и коду из кабинета (команды `pair`, затем `pull` — см. [ApiPay и MCP](https://apipay.kz/guides/podklyuchenie-apipay-mcp)), или вы вписываете их сами.

```bash
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz listen --forward-to http://localhost:3000/webhook --daemon
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz listen --status
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz listen --stop
```

`--daemon` держит прослушивание в фоне; без него команда работает, пока открыто окно терминала. `--status` показывает, идёт ли прослушивание и куда пересылает. Запускайте команду только этой полной командой, с адресом и версией, из корня проекта. Не сокращайте её до `npx apipay-cli`. Флаг `--cache .apipay/npm-cache` держит кеш npm внутри проекта — так команда работает и там, где домашняя папка закрыта на запись; перед первым запуском добавьте строку `.apipay/` в файл `.gitignore`: туда же попадает журнал фонового прослушивания. Если `npx` всё равно пишет «permission denied» или `EPERM`, не запускайте его через sudo и не меняйте владельца папок: запустите ту же команду ещё раз из корня проекта, а если не помогло — используйте туннель ниже. Сообщения `npm notice` с советом обновить npm можно пропустить: для этой команды ничего устанавливать не нужно.

Ограничения:

- **Только песочница.** В рабочем режиме сессию не открыть (`403 listen_sandbox_only`): уведомления там приходят только на публичный HTTPS-адрес на вашем домене. Перед тем как поставить боевой адрес, остановите прослушивание: `listen --stop`.
- **Не все события.** Через `listen` идут события счёта (`invoice.status_changed`, `invoice.qr_scanned`), `invoice.refunded` и `qr_refund.*`. Подписки, чеки, касса, каталог и тестовое событие `webhook.test` («Тест webhook») приходят только на публичный адрес — для них нужен туннель ниже.
- **Пока прослушивание идёт**, уведомления песочницы этого ключа о счетах и возвратах приходят на компьютер, а не на адрес вебхука ключа.
- **Повтор вручную недоступен.** Доставка пишется в журнал доставок с пометкой «Через помощника на компьютере»; повторить такую строку из кабинета или через `replay_delivery` нельзя — событие повторяют заново в песочнице. Автоматические повторы после сбоя обработчика идут как обычно.

## Туннель ngrok: 5 шагов

### 1. Установите ngrok

```bash
# macOS
brew install ngrok

# Или скачайте: https://ngrok.com/download
```

Зарегистрируйтесь на [ngrok.com](https://ngrok.com) (бесплатно) и добавьте токен:

```bash
ngrok config add-authtoken ВАШ_ТОКЕН
```

### 2. Запустите тоннель

```bash
ngrok http 3000

# Вывод:
# Forwarding  https://a1b2c3d4.ngrok-free.dev -> http://localhost:3000
```

Скопируйте HTTPS URL.

### 3. Вставьте URL в ApiPay

[ApiPay.kz → Настройки → Подключение](https://apipay.kz/settings) → Webhook URL:

```
https://a1b2c3d4.ngrok-free.dev/webhook
```

> Туннельный адрес принимается в **песочнице**. В рабочем режиме организация, ещё не прошедшая проверку анкеты о бизнесе, сохранить туннель не сможет — вернётся `422 webhook_url_tunnel_forbidden`; для рабочего режима нужен постоянный HTTPS-адрес на вашем домене.

> **Секрет подписи — не API-ключ.** Подпись `X-Webhook-Signature` считается по секрету, а не по ключу доступа. Если Webhook URL указан при создании API-ключа, секрет выдаётся сразу вместе с ключом; для уже созданного ключа действие «Создать секретный ключ подписи» появляется только после сохранения Webhook URL. Секрет показывается один раз — сохраните его сразу. Смена API-ключа секрет не меняет.

### 4. Отправьте тестовый вебхук

В настройках API ключа нажмите **«Тест webhook»**.

### 5. Откройте Inspect UI

Перейдите на [localhost:4040](http://localhost:4040) — видны все входящие запросы: заголовки, тело, статус.

> Даже без сервера Inspect UI покажет запросы (502, но тело видно).

> **Закончив тест — верните постоянный адрес.** Пока в поле стоит выключенный туннель, доставки на этот API-ключ копят неудачи: отправка вебхуков по ключу сначала приостанавливается, а после длинной череды отказов прекращается совсем, и события за это время не досылаются — состояние счетов после этого сверяют через `GET /invoices/{id}`. Сохранение постоянного адреса и успешный «Тест webhook» возвращают отправку.

## Webhook API

### События

| Событие | Когда |
|---------|-------|
| `invoice.status_changed` | Статус счёта изменился |
| `invoice.refunded` | Создан возврат |
| `subscription.payment_succeeded` | Успешный платёж подписки |
| `subscription.payment_failed` | Неуспешный платёж подписки |
| `subscription.grace_period_started` | Grace-период подписки |
| `subscription.expired` | Подписка истекла |
| `webhook.test` | Тестовое событие |

### Формат запроса

```
POST /webhook
Content-Type: application/json
X-Webhook-Signature: sha256=<HMAC-SHA256 от сырого тела, hex>

{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 42,
    "external_order_id": "order_abc",
    "status": "paid",
    "amount": "15000.00",
    "paid_at": "2026-01-15T10:30:00Z"
  },
  "source": "my-api-key"
}
```

### Верификация подписи

HMAC-SHA256 от **сырого тела** запроса (до парсинга JSON — подпись по перекодированному JSON не сойдётся). Заголовок: `X-Webhook-Signature: sha256=<hex>`. Ожидаемое значение стройте с тем же префиксом `sha256=` и сравнивайте constant-time.

### Retry-политика

`invoice.status_changed` в рабочем режиме — до 11 попыток доставки (первая + 10 повторов) с нарастающими паузами: 10 с, 30 с, 1, 1.5, 2, 5, 10, 15, 30 и 60 минут — около двух часов суммарно. У других событий сетка своя: например, `invoice.qr_scanned` повторяется около 16 минут, `qr_refund.*` — около 50 минут. Успех — любой `2xx` быстрее 5 секунд.

Повторяются только ответы `5xx`, `429` и сетевые ошибки. Прочие `4xx` (в том числе `401` при несошедшейся подписи и `404` от закрытого туннеля) **не повторяются** — попытка сразу фиксируется как завершённая, состояние счёта после этого сверяют через `GET /invoices/{id}`. В песочнице `invoice.status_changed` доставляется за 3 попытки (5 с, 15 с).

## Минимальный сервер (Node.js)

Этот сервер нужен, только если обработчика ещё нет и вы хотите посмотреть на уведомления. Если обработчик уже есть в вашем приложении, второй сервер не запускайте: направьте `ngrok http <порт>` или `listen --forward-to` на порт, где приложение уже работает.

```javascript
const http = require('http')
const crypto = require('crypto')

const PORT = 3000
const WEBHOOK_SECRET = process.env.APIPAY_WEBHOOK_SECRET || ''

const server = http.createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/webhook') {
    let body = ''
    req.on('data', chunk => { body += chunk })
    req.on('end', () => {
      // Заголовок приходит как 'sha256=<hex>' — ожидаемое значение строим с тем же
      // префиксом и сравниваем constant-time.
      const signature = req.headers['x-webhook-signature'] || ''
      const expected = 'sha256=' + crypto
        .createHmac('sha256', WEBHOOK_SECRET)
        .update(body)
        .digest('hex')

      const sigBuf = Buffer.from(signature)
      const expBuf = Buffer.from(expected)
      if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
        console.log('!! Подпись не совпадает')
        res.writeHead(401)
        return res.end('Invalid signature')
      }

      const data = JSON.parse(body)
      console.log('Webhook:', data.event, JSON.stringify(data, null, 2))

      res.writeHead(200, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify({ status: 'ok' }))
    })
  } else {
    res.writeHead(404)
    res.end('Not found')
  }
})

server.listen(PORT, () => {
  console.log(`Webhook сервер: http://localhost:${PORT}/webhook`)
  console.log('Запустите ngrok: ngrok http ' + PORT)
})
```

Запуск: `node --env-file=.env server.js` (Node.js 20.6+; на более старом задайте переменную `APIPAY_WEBHOOK_SECRET` перед запуском), затем в другом терминале `ngrok http 3000` или `apipay-cli listen --forward-to http://localhost:3000/webhook`.

## FAQ

- **ngrok бесплатный?** — Да, для тестирования вебхуков бесплатного плана достаточно: постоянный dev-домен и месячный лимит запросов с запасом. Актуальные лимиты и цены — на [ngrok.com/pricing](https://ngrok.com/pricing).
- **Нужна карта?** — Для HTTP/HTTPS-тоннелей обычно нет, для TCP — требуется; условия задаёт ngrok.
- **Альтернативы?** — LocalXpose, Cloudflare Tunnel, Loophole. В песочнице — `apipay-cli listen` без туннеля (раздел выше), но только для событий счёта и возвратов.

## Ссылки

- [HTML-версия этого руководства](https://apipay.kz/local-testing)
- [API документация](https://apipay.kz/docs.html)
- [Lovable интеграция](https://apipay.kz/lovable-integration)
- [Промпты и руководства](https://apipay.kz/prompts)
- [OpenAPI спецификация](https://apipay.kz/openapi.json)
