Перейти к содержимому
PortunaPortunaPortunaДокументацияTestnet

Вебхуки

Вебхуки сообщают бэкенду, чем закончились каждый sweep, каждое снятие делегации и каждое развёртывание делегата горячего кошелька (котла), а также когда газ-кошелёк подходит к концу. То же состояние всегда можно прочитать GET-запросами — вебхуки избавляют от опроса.

Окно терминала
curl -X PUT "https://api-testnet.portuna.io/v1/webhook" \
-H "Authorization: Bearer $PORTUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://processing.example/hooks/sweeps", "low_balance": [{"chain_id": 11155111, "threshold": "20000000000000000"}]}'
  • Адрес — только https и только публичный. Редиректы не выполняются.
  • В ответе есть secret вида whsec_…, которым подписывается каждая доставка. Храните его на бэкенде.
  • При смене адреса повторным PUT секрет сохраняется. Чтобы получить новый секрет, удалите вебхук (DELETE /v1/webhook) и задайте заново; при удалении ещё не доставленные события отбрасываются.
  • GET /v1/webhook показывает текущие настройки, DELETE /v1/webhook удаляет их.
ТипКогдаdata
sweep.sweptSweep перевёл средства.Sweep
sweep.failedSweep откатился в сети или исчерпал попытки.Sweep
sweep.rejectedSweep так и не был отправлен.Sweep
revocation.revoked, revocation.failed, revocation.rejectedСнятие делегации завершилось.Revocation
hot_wallet.active, hot_wallet.failedДелегат горячего кошелька развёрнут — или нет.HotWallet
balance.lowГаз-кошелёк опустился ниже порога.BalanceLow
webhook.testВы запросили тестовое событие.WebhookTest

Тело — само событие: {"id", "type", "created_at", "data"}, где data — объект в том виде, в каком его показал бы GET. Могут появиться новые типы событий: принимайте и игнорируйте те, что не обрабатываете.

Каждое событие приходит запросом POST с телом в JSON и такими заголовками:

ЗаголовокЗначение
Portuna-Signaturet=<секунды unix>,v1=<HMAC-SHA256 в hex>
Portuna-Eventтип события, например sweep.swept
Portuna-Deliveryid события
Content-Typeapplication/json
  • Хотя бы один раз. Событие может прийти несколько раз: обрабатывайте каждый id события один раз.
  • В любом порядке. События могут прийти не по порядку: опирайтесь на статус в data, а не на порядок событий.
  • Повторы. Любой ответ, кроме 2xx, — в том числе редирект, — и отсутствие ответа в течение 10 секунд считаются неудачей. Событие отправляется снова через 10 с, 20 с, 40 с и так далее, с паузой до часа, всего 12 попыток. После этого оно получает статус failed.
  • Отвечайте быстро. Возвращайте 2xx, как только событие надёжно сохранено, а тяжёлую работу делайте потом.

GET /v1/webhook/deliveries показывает последние события и их доставку: pending, delivered или failed, число попыток, последний HTTP-код вашего приёмника и время следующей попытки. В кабинете — тот же журнал.

v1 — это HMAC-SHA256 в hex от строки <t>.<тело> с секретом вебхука в качестве ключа, где <t> — значение t из заголовка, а <тело> — сырое тело запроса. Чтобы проверить доставку:

  1. прочитайте сырое тело — до разбора JSON: заново сериализованный JSON не совпадёт;
  2. возьмите t и v1 из заголовка; если v1 несколько, считается последний;
  3. посчитайте HMAC и сравните его с v1 за постоянное время;
  4. убедитесь, что t отличается от ваших часов не больше чем на 5 минут в любую сторону — так старую доставку не повторить.

Каждая попытка подписывается заново, со своим t.

В Go SDK проверяет и разбирает событие ParseWebhook:

http.HandleFunc("POST /hooks/sweeps", func(w http.ResponseWriter, r *http.Request) {
// Verify the body exactly as it arrived, before parsing it.
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "cannot read the body", http.StatusBadRequest)
return
}
event, err := portuna.ParseWebhook(secret, r.Header.Get(portuna.SignatureHeader), body)
if err != nil {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// Delivery is at least once: an event may come again, with the same id.
if handled(event.ID) {
w.WriteHeader(http.StatusNoContent)
return
}
switch event.Type {
case portuna.EventSweepSwept, portuna.EventSweepFailed, portuna.EventSweepRejected:
sweep, err := event.Sweep()
if err != nil {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
// Events may come out of order: the status in the data is the current one.
log.Printf("sweep %s (%s): %s %s %s", sweep.ID, sweep.ExternalID, sweep.Status, sweep.SweptAmount, sweep.Reason)
case portuna.EventBalanceLow:
alert, err := event.BalanceLow()
if err != nil {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
log.Printf("top up the gas wallet on chain %d: %s wei left", alert.ChainID, alert.Balance)
default:
// Other and future event types: accept them.
}
markHandled(event.ID)
w.WriteHeader(http.StatusNoContent)
})

Проверьте свою реализацию на этой доставке: при now = 1800000000 она должна пройти, а с другим секретом или изменённым телом — нет.

секретwhsec_test
тело{"id":"e1","type":"sweep.swept"}
заголовокt=1800000000,v1=b50785faa40c2334e54bc4a32003239e6e21eb93c63eb32d18598c18b0be5026
Окно терминала
curl -X POST "https://api-testnet.portuna.io/v1/webhook/test" -H "Authorization: Bearer $PORTUNA_API_KEY"

Запрос ставит в очередь событие webhook.test с данными {"message": "Test event: the webhook works."} — оно подписывается и доставляется, как любое другое, — и отвечает 202 с id события. Если вебхук не задан, ответ — 404 not_found.

low_balance в PUT /v1/webhook задаёт порог в wei для каждой сети, по которой нужен алерт. Баланс газ-кошелька проверяется раз в минуту. Когда он опускается ниже порога, приходит одно событие balance.low; алерт снова взводится, когда баланс вернётся выше порога. Поле low в GET /v1/webhook показывает, ниже ли порога баланс в сети сейчас. Сети без порога — без алерта.

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