Вебхуки
Вебхуки сообщают бэкенду, чем закончились каждый 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.swept | Sweep перевёл средства. | Sweep |
sweep.failed | Sweep откатился в сети или исчерпал попытки. | Sweep |
sweep.rejected | Sweep так и не был отправлен. | 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-Signature | t=<секунды unix>,v1=<HMAC-SHA256 в hex> |
Portuna-Event | тип события, например sweep.swept |
Portuna-Delivery | id события |
Content-Type | application/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 из
заголовка, а <тело> — сырое тело запроса. Чтобы проверить доставку:
- прочитайте сырое тело — до разбора JSON: заново сериализованный JSON не совпадёт;
- возьмите
tиv1из заголовка; еслиv1несколько, считается последний; - посчитайте HMAC и сравните его с
v1за постоянное время; - убедитесь, что
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)})В TypeScript SDK на Node.js — parseWebhookSync из @portuna/sdk/node; вне Node.js (edge-окружения, Deno, Bun) —
асинхронный parseWebhook из @portuna/sdk на Web Crypto:
function handle(event: Event): void { switch (event.type) { case 'sweep.swept': case 'sweep.failed': case 'sweep.rejected': // Events may come out of order: the status in the data is the current one. console.log(`sweep ${event.data.id} (${event.data.external_id}): ${event.data.status}`) break case 'balance.low': console.log(`top up the gas wallet on chain ${event.data.chain_id}: ${event.data.balance} wei left`) break default: // Other and future event types: accept them. }}
createServer((req, res) => { const chunks: Buffer[] = [] req.on('data', (chunk: Buffer) => chunks.push(chunk)) req.on('end', () => { // Verify the body exactly as it arrived, before parsing it. const body = Buffer.concat(chunks) let event: Event try { event = parseWebhookSync(secret, String(req.headers[signatureHeader.toLowerCase()] ?? ''), body) } catch { res.writeHead(401).end() return } // Delivery is at least once: an event may come again, with the same id. if (!handled.has(event.id)) { handle(event) handled.add(event.id) } res.writeHead(204).end() })}).listen(8080)SDK не нужен, хватает стандартной библиотеки:
# Verifies the Portuna-Signature header of a webhook delivery, with the standard library only.# The docs tests run it against the signature vectors the SDKs and the service share.import hashlibimport hmacimport reimport time
TOLERANCE = 300 # seconds between the signature time and now, either way
class SignatureError(Exception): pass
def verify_webhook(secret: str, header: str, body: bytes, now: float | None = None, tolerance: int = TOLERANCE) -> None: """Raises SignatureError unless the header signs body with secret within tolerance of now.
body must be the request body exactly as received, before any JSON parsing. """ ts, sig = None, None for part in header.split(","): key, _, value = part.partition("=") if key == "t": ts = value elif key == "v1": sig = value if ts is None or not re.fullmatch(r"[+-]?[0-9]+", ts) or not sig: raise SignatureError("malformed signature header") if abs((time.time() if now is None else now) - int(ts)) > tolerance: raise SignatureError("signature timestamp out of tolerance") expected = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig.encode(), expected.encode()): raise SignatureError("signature mismatch")В приложении на Flask:
@app.post("/hooks/sweeps")def sweeps_hook(): try: verify_webhook(SECRET, request.headers.get("Portuna-Signature", ""), request.get_data()) except SignatureError: abort(401) event = request.get_json() ... # handle event["type"] and event["data"], once per event["id"] return "", 204Проверьте свою реализацию на этой доставке: при 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 показывает, ниже ли порога баланс в сети сейчас.
Сети без порога — без алерта.
Ставьте порог на день-два обычного расхода газа: если газ-кошелёк опустеет, задания ждут и повторяются, а когда попытки кончатся — падают.