Контекст: я пилю сервис туннелей (грубо — свой ngrok: берёт локальный порт и вешает на него публичный адрес с валидным TLS). В какой-то момент дошёл до денег — прикрутил онлайн-кассу, чтобы люди могли оплачивать платные тарифы. И тут же упёрся в классическую стену: кассу невозможно протестировать на localhost.

Проблема в том, что касса общается с тобой асинхронно и по своей инициативе. Пользователь оплатил на стороне платёжки — платёжка шлёт тебе вебхук (IPN, callback, notification — называют по-разному): «заказ такой-то оплачен, выдавай услугу». Этот запрос идёт снаружи к тебе. А у твоего ноутбука нет публичного адреса, и касса при всём желании не достучится до http://127.0.0.1:8080/api/tariff/crypto-callback. Пока callback-URL недоступен из интернета, ты протестируешь ровно ничего дальше кнопки «оплатить».

Обычно это лечат деплоем: залил на стейдж, потыкал, нашёл опечатку в парсинге тела вебхука, снова залил. Цикл в несколько минут на каждую однострочную правку. И вот я сижу, смотрю на этот цикл, смотрю на свой недоделанный сервис туннелей, назначение которого — прокидывать локальные порты в интернет ровно для таких случаев… и до меня доходит, что инструмент для решения моей проблемы я держу в руках. Точнее, пишу.

Почему не ngrok

Резонный вопрос: туннели — давно решённая задача, есть ngrok, localtunnel, cloudflared. Зачем городить своё? Отвечу честно, потому что это и есть причина, по которой я вообще влез в эту нишу: мне нужен был контроль над доменом.

У ngrok на бесплатном тарифе поддомен случайный и меняется при каждом перезапуске — а для callback-URL кассы это боль: адрес приходится переписывать в кабинете платёжки после каждого рестарта. Фиксированный или свой домен — уже платно, и всё равно ты живёшь на чужом домене по чужим правилам: захотят — поменяют политику, лимиты или тарифы, и твои стабильные URL, на которые завязаны вебхуки, окажутся заложником вендора. Для pet-проекта, который я планирую растить, отдавать инфраструктуру доменов наружу не хотелось — хотелось свой домен, предсказуемые поддомены и возможность в любой момент прикрутить свои правила выдачи. Так что «своё» здесь — не NIH-синдром, а осознанный выбор: контроль над доменом стоит того, чтобы написать точку входа самому. А тест кассы заодно стал первым реальным применением этого контроля.

Змея кусает свой хвост

Дальше был момент, ради которого я и пишу эту статью. Чтобы протестировать кассу, встроенную в мой сервис, я поднял туннель моим же сервисом, прокинул через него свой же локальный бэкенд наружу и скормил получившийся публичный адрес кассе как callback-URL. То есть сервис прокинул сам себя, чтобы проверить собственную оплату.

Вишенка: делал я это на бесплатном тарифе. Своего же сервиса. Так я стал своим первым живым пользователем — в максимально буквальном смысле, включая «протестировал бесплатный тариф на реальной задаче». Дешёвый способ провести product research: если инструментом невозможно нормально пользоваться, ты первый об это спотыкаешься.

Выглядело это так:

bash

# поднимаем туннель на локальный порт бэкенда — тем самым сервисом,
# кассу которого мы и собираемся тестировать
nozi http 8080
# → https://ab12cd.nozi.io  →  127.0.0.1:8080

Публичный адрес отдаём кассе как URL для IPN — в кабинете NOWPayments или прямо в запросе на создание инвойса:

json

{
  "price_amount": 6,
  "price_currency": "usd",
  "order_id": "42_1_2026-07-25",
  "ipn_callback_url": "https://ab12cd.nozi.io/api/tariff/crypto-callback"
}

Теперь когда касса «оплачивает» тестовый инвойс (у NOWPayments есть песочница, у большинства касс тоже), её вебхук летит на публичный поддомен, проходит через туннель и падает прямо в дебаггер на ноутбуке. Точка останова в обработчике, тело запроса — глазами, правка — проверяется мгновенно, без единого деплоя. Змея довольна.

Оговорюсь: туннель тут мой собственный, и я не строю из себя независимый обзор. Но статья не про «зацените мой сервис» — техника ниже работает с любым туннелем (ngrok, localtunnel, cloudflared), и если вы никогда не отлаживали платёжку локально, здесь есть что унести с собой. А самоироничная часть — просто бонус к тому, что дальше идёт полезное.

Три грабли, ради которых стоило городить весь этот уроборос

Поднять туннель — минутное дело. Интереснее то, что платёжный вебхук нельзя обрабатывать наивно. Вот что я бы хотел знать до того, как полез.

1. Тело вебхука — это триггер, а не источник правды

Соблазн большой: в callback'е уже лежит payment_status: finished, бери и выдавай. Так нельзя. Вебхук приходит по HTTP на публичный URL — значит, его может подделать кто угодно, кто этот URL узнал. Полагаться на присланный статус — дырка, через которую услугу выдают без оплаты.

Правильно: вебхук — это только сигнал «сходи проверь», а фактический статус платежа запрашиваем сами через API кассы по её же payment id:

go

// тело IPN — только триггер. Авторитетное состояние тянем из API,
// а не из (недоверенного) вебхука.
status, err := gateway.GetPaymentStatus(ctx, payload.PaymentID)
if err != nil {
    return err
}
if !isPaid(status.PaymentStatus) {
    return nil // waiting / confirming / failed — выдавать ещё нечего
}

2. Подпись вебхука проверяем всегда

Кассы подписывают тело вебхука HMAC'ом с общим секретом. Проверка отсекает левые запросы на входе, до любой логики:

go

if !verifySignature(ipnSecret, rawBody, signatureHeader) {
    return errBadSignature // 401, дальше не идём
}

Туннель тут подкидывает отдельную пользу: раз публичный URL реально принимает запросы из интернета, до него долетают и «неправильные». Пока я отлаживался, поймал пару обращений от сканеров, которые долбятся по случайным путям, — хорошее напоминание, что endpoint публичный и валидация тела обязательна, а не «потом добавлю».

3. Идемпотентность: один платёж — одна выдача

Кассы ретраят вебхук. Ответил не-200, завис, или касса просто перестраховалась — один и тот же callback прилетит несколько раз. Без защиты пользователь получит услугу дважды за одну оплату. Лечится дедупликацией по идентификатору операции:

go

opID := "nowpayments_" + status.PaymentID
if alreadyProcessed(ctx, opID) {
    return nil // уже выдали, тихо выходим
}
// ... выдаём услугу и фиксируем opID в той же транзакции

Ретраи, кстати, проще всего тестировать именно через туннель: касса в песочнице шлёт вебхук по-настоящему, и повтор воспроизводится честно — например, вернул на первый запрос 500 и смотришь, как приходит второй.

Что в итоге

Весь цикл «создал инвойс → оплатил в песочнице → поймал вебхук → выдал услугу → проверил повтор и подделку» я прогнал, не выходя за пределы своей машины и не задеплоив ни одной промежуточной правки. Инструментом для этого оказался мой же сервис на своём же бесплатном тарифе — и заодно это был самый честный тест продукта из возможных: если бы туннелем было больно пользоваться, я бы первым это почувствовал прямо посреди отладки кассы.

Мораль без шуток: как только сервису нужно принимать что-то извне по инициативе чужой стороны — платёжки, вебхуки мессенджеров, пинги CI, — локальный публичный адрес перестаёт быть роскошью и становится частью тест-лупа. Туннель — самый дешёвый способ его получить.

А тот самый сервис, который прокинул сам себя, — nozi.io. Работает без карты (оплата криптой), бесплатного тарифа хватает, чтобы поднять туннель и прогнать тест кассы — проверено на себе, буквально. Технику выше берите с любым туннелем, а если решите попробовать мой — буду рад фидбеку в комментариях: сейчас как раз собираю первых пользователей, которые не я.