RugWard Pay
Вебхуки и проверка подписи
Получайте подписанное HTTPS-уведомление при каждом изменении статуса счёта, проверяйте его в curl, Node.js, Python или PHP и безопасно обрабатывайте повторы и дубликаты.
На этой странице
Как работают вебхуки#
Когда счёт меняет статус, RugWard отправляет POST с JSON-телом на ваш эндпоинт. Каждый запрос подписан вашим секретом подписи (signing secret), поэтому вы можете доказать, что он пришёл от RugWard и не был изменён.

Настройка#
- В панели укажите эндпоинт в поле Webhook и нажмите Save.
- Секрет подписи (
whsec_и 40 символов) показывается один раз. Сохраните его на сервере рядом с ключом API. - Нажмите Send test ping. Панель покажет HTTP-статус, который вернул ваш эндпоинт.
- Журнал доставки под блоком показывает каждое событие, его результат и число попыток.
Требования к эндпоинту:
- только
https://, на стандартном порту (без:8443), не длиннее 500 символов; - публичное имя хоста: IP-адреса,
localhostи частные сети отклоняются; - ответ в течение 10 секунд с любым статусом 2xx. Редиректы не выполняются: ответ 3xx считается ошибкой.
New signing secret заменяет секрет сразу; немедленно разверните новый (подписи старым секретом перестают действовать мгновенно). Сохранение пустого URL отключает вебхуки.
События#
| Событие | Отправляется, когда |
|---|---|
invoice.pending | платёж замечен, ожидаются подтверждения или остаток суммы |
invoice.paid | оплачен полностью и подтверждён: отгружайте |
invoice.overpaid | оплачен с переплатой более 1%: отгружайте |
invoice.underpaid | фиксация курса закончилась, а подтверждено слишком мало |
invoice.late | платёж пришёл после истечения срока |
invoice.expired | срок истёк без оплаты |
invoice.canceled | вы его отменили |
ping | кнопка Send test ping в панели |
Каждое событие отправляется не более одного раза на счёт. Тестовые счета никогда не отправляют вебхуки.
Запрос#
POST /rugward-webhook HTTP/1.1
Host: shop.example
Content-Type: application/json
User-Agent: RugWard-Pay-Webhook/1.0
RugWard-Event-Id: Zx3kQ8pL2mN7vR1tY5wB9c:paid
RugWard-Event: invoice.paid
RugWard-Signature: t=1793324105,v1=5f0c3d0e8b1a…(64 hex characters){
"id": "Zx3kQ8pL2mN7vR1tY5wB9c:paid",
"type": "invoice.paid",
"created": 1793324100,
"data": {
"invoice": {
"id": "Zx3kQ8pL2mN7vR1tY5wB9c",
"status": "paid",
"amountUsd": "25.00",
"orderId": "1234",
"description": "1 x T-shirt, size M",
"redirectUrl": "https://shop.example/thanks?order=1234",
"createdAt": 1793323282069,
"expiresAt": 1793326882069,
"paidAt": 1793324100512,
"assets": ["BTC", "ETH-8453", "USDC-8453"],
"test": false,
"quotes": [{ "id": 4711, "asset": "USDC-8453", "amount": "25.000629", "rateUsd": "1", "address": "0x…", "expiresAt": 1793325082069 }],
"payments": [{ "txid": "0x…", "asset": "USDC-8453", "amount": "24.875626", "confirmations": 30, "required": 30, "confirmed": true, "seenAt": 1793324012000, "explorer": "https://basescan.org/tx/0x…" }]
}
}
}{ "id": "ping:1793324000000", "type": "ping", "created": 1793324000, "data": {} }idимеет вид<invoice id>:<status>и также передаётся в заголовкеRugWard-Event-Id: используйте его, чтобы отбрасывать дубликаты.createdуказан в секундах Unix;data.invoice— тот же объект, что возвращаетGET /api/pay/merchant/invoices/:id.
Проверка подписи#
Заголовок выглядит так: RugWard-Signature: t=1793324100,v1=5f2c… (64 шестнадцатеричных символа).
- Прочитайте сырое тело запроса точно в том виде, в каком оно пришло (до любого разбора JSON).
- Вычислите
HMAC-SHA256(secret, t + "." + raw_body)и закодируйте в hex. - Сравните с
v1за постоянное время. - Отклоните запрос, если
tотличается от ваших часов более чем на 5 минут (защита от повторов). - Только после этого разбирайте JSON.
Если сначала разобрать JSON и снова сериализовать, байты изменятся и подпись не совпадёт. Всегда проверяйте подпись по сырому телу.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.RUGWARD_WEBHOOK_SECRET; // whsec_…
const app = express();
// Use the RAW body: the signature covers the exact bytes RugWard sent.
app.post("/rugward-webhook", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(req.get("RugWard-Signature") || "");
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return res.sendStatus(400);
const want = crypto.createHmac("sha256", SECRET).update(`${m[1]}.${raw}`).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(want), Buffer.from(m[2]))) return res.sendStatus(400);
const event = JSON.parse(raw);
// De-duplicate: the same event id can arrive more than once.
// if (alreadyProcessed(event.id)) return res.sendStatus(200);
if (event.type === "invoice.paid" || event.type === "invoice.overpaid") {
const inv = event.data.invoice;
// markOrderPaid(inv.orderId, inv.id); // ship only now
}
res.sendStatus(200); // any 2xx = delivered
});
app.listen(3000);import hashlib, hmac, json, os, re, time
from flask import Flask, abort, request
SECRET = os.environ["RUGWARD_WEBHOOK_SECRET"].encode() # whsec_…
app = Flask(__name__)
@app.post("/rugward-webhook")
def rugward_webhook():
raw = request.get_data() # raw bytes, exactly as sent
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", request.headers.get("RugWard-Signature", ""))
if not m or abs(time.time() - int(m.group(1))) > 300:
abort(400)
want = hmac.new(SECRET, m.group(1).encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, m.group(2)):
abort(400)
event = json.loads(raw)
if event["type"] in ("invoice.paid", "invoice.overpaid"):
inv = event["data"]["invoice"]
# mark_order_paid(inv["orderId"], inv["id"]) # de-duplicate on event["id"]
return "", 200<?php
$secret = getenv("RUGWARD_WEBHOOK_SECRET"); // whsec_…
$raw = file_get_contents("php://input");
$sig = $_SERVER["HTTP_RUGWARD_SIGNATURE"] ?? "";
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $sig, $m) || abs(time() - (int)$m[1]) > 300) {
http_response_code(400); exit;
}
$want = hash_hmac("sha256", $m[1] . "." . $raw, $secret);
if (!hash_equals($want, $m[2])) { http_response_code(400); exit; }
$event = json_decode($raw, true);
if (in_array($event["type"], ["invoice.paid", "invoice.overpaid"], true)) {
$inv = $event["data"]["invoice"];
// mark_order_paid($inv["orderId"], $inv["id"]); // de-duplicate on $event["id"]
}
http_response_code(200);Чтобы проверить эндпоинт вручную, подпишите тело сами:
SECRET='whsec_…' # your signing secret
BODY='{"id":"ping:1","type":"ping","created":1793324000,"data":{}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST https://shop.example/rugward-webhook \
-H "Content-Type: application/json" \
-H "RugWard-Event: ping" \
-H "RugWard-Signature: t=$T,v1=$SIG" \
--data "$BODY"Повторы и дубликаты#
Если эндпоинт не ответил вовремя или вернул не 2xx, RugWard повторяет отправку: всего до 4 попыток, примерно через 30 секунд, 2 минуты и 8 минут. После этого событие помечается в журнале доставки как неудачное. Поэтому ваш обработчик должен:
- быстро отвечать 2xx, а долгую работу выполнять потом;
- быть идемпотентным: сохраняйте
RugWard-Event-Idи пропускайте уже обработанные события; - не зависеть от порядка: при сомнениях перечитайте счёт через API.
Вебхуки — быстрый сигнал, но не единственный. Если ваш сервер какое-то время был недоступен, получите список последних счетов через API и сравните статусы.
Устранение неполадок#
| Симптом | Вероятная причина |
|---|---|
| "Webhook URL must be https:// with a public host name" | IP-адрес, нестандартный порт, http:// или внутреннее имя хоста |
| пинг показывает HTTP 301/302/308 | ваш сервер делает редирект (например http→https или нет завершающего слэша); укажите итоговый URL |
| пинг показывает 405 | маршрут не принимает POST |
| подпись никогда не совпадает | тело было разобрано или перекодировано перед хешированием, используется не тот секрет (после New signing secret) или неверно идут часы сервера |
| нет событий для тестовых счетов | так и задумано: тестовые счета никогда не отправляют вебхуки |
Обновлено: 30 октября 2026 г.