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

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

## Зачем

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

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

## 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-политика

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

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

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

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

const PORT = 3000
const 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 server.js`, затем `ngrok http 3000`.

## FAQ

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

## Ссылки

- [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)
