RugWard Pay
Webhooks und Signaturprüfung
Erhalte bei jeder Statusänderung einer Rechnung eine signierte HTTPS-Benachrichtigung, prüfe sie in curl, Node.js, Python oder PHP und behandle Wiederholungen und Duplikate sicher.
Auf dieser Seite
So funktionieren Webhooks#
Wenn sich der Status einer Rechnung ändert, sendet RugWard einen POST mit JSON-Body an deinen Endpoint. Jede Anfrage ist mit deinem Signaturgeheimnis (signing secret) signiert, sodass du nachweisen kannst, dass sie von RugWard stammt und nicht verändert wurde.

Einrichtung#
- Gib im Dashboard deinen Endpoint unter Webhook ein und klicke auf Save.
- Das Signaturgeheimnis (
whsec_gefolgt von 40 Zeichen) wird nur einmal angezeigt. Bewahre es auf deinem Server neben dem API-Schlüssel auf. - Klicke auf Send test ping. Das Dashboard zeigt den HTTP-Status, den dein Endpoint zurückgegeben hat.
- Das Zustellprotokoll unter dem Bereich zeigt jedes Ereignis, sein Ergebnis und die Anzahl der Versuche.
Regeln für den Endpoint:
- nur
https://, auf dem Standardport (kein:8443), maximal 500 Zeichen; - ein öffentlicher Hostname: IP-Adressen,
localhostund private Netze werden abgelehnt; - innerhalb von 10 Sekunden mit einem beliebigen 2xx-Status antworten. Weiterleitungen werden nicht verfolgt: eine 3xx-Antwort gilt als Fehler.
New signing secret ersetzt das Geheimnis sofort; setze das neue umgehend ein (Signaturen mit dem alten Geheimnis enden sofort). Das Speichern einer leeren URL schaltet Webhooks ab.
Ereignisse#
| Ereignis | Gesendet, wenn |
|---|---|
invoice.pending | eine Zahlung gesehen wurde, wartet auf Bestätigungen oder den Restbetrag |
invoice.paid | vollständig bezahlt und bestätigt: versenden |
invoice.overpaid | mit mehr als 1 % zu viel bezahlt: versenden |
invoice.underpaid | die Kurssperre endete mit zu wenig bestätigtem Betrag |
invoice.late | eine Zahlung kam nach Ablauf an |
invoice.expired | ohne Zahlung abgelaufen |
invoice.canceled | du hast sie storniert |
ping | die Schaltfläche Send test ping im Dashboard |
Jedes Ereignis wird pro Rechnung höchstens einmal ausgelöst. Testrechnungen senden nie Webhooks.
Die Anfrage#
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": {} }idist<invoice id>:<status>und steht auch im HeaderRugWard-Event-Id: nutze sie, um Duplikate zu ignorieren.createdist in Unix-Sekunden;data.invoiceist dasselbe Objekt, dasGET /api/pay/merchant/invoices/:idliefert.
Signatur prüfen#
Der Header sieht so aus: RugWard-Signature: t=1793324100,v1=5f2c… (64 Hex-Zeichen).
- Lies den rohen Request-Body genau so, wie er ankam (vor jedem JSON-Parsing).
- Berechne
HMAC-SHA256(secret, t + "." + raw_body)und kodiere ihn hexadezimal. - Vergleiche ihn mit
v1in konstanter Zeit. - Lehne die Anfrage ab, wenn
tmehr als 5 Minuten von deiner Uhr abweicht (verhindert Replays). - Erst dann das JSON parsen.
Wird das JSON zuerst geparst und neu serialisiert, ändern sich die Bytes und die Signatur bricht. Prüfe die Signatur immer am rohen Body.
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);Um deinen Endpoint manuell zu testen, signiere selbst einen Body:
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"Wiederholungen und Duplikate#
Wenn dein Endpoint in ein Timeout läuft oder nicht mit 2xx antwortet, versucht RugWard es erneut: insgesamt bis zu 4 Versuche, nach etwa 30 Sekunden, 2 Minuten und 8 Minuten. Danach wird das Ereignis im Zustellprotokoll als fehlgeschlagen markiert. Dein Handler sollte daher:
- schnell mit 2xx antworten und langsame Arbeit danach erledigen;
- idempotent sein:
RugWard-Event-Idspeichern und bereits verarbeitete Ereignisse überspringen; - nicht von der Reihenfolge abhängen: im Zweifel die Rechnung per API neu lesen.
Webhooks sind ein schnelles Signal, aber nicht das einzige. War dein Server eine Weile nicht erreichbar, liste die letzten Rechnungen per API auf und vergleiche die Status.
Fehlerbehebung#
| Symptom | Wahrscheinliche Ursache |
|---|---|
| "Webhook URL must be https:// with a public host name" | eine IP-Adresse, ein eigener Port, http:// oder ein interner Hostname |
| Ping zeigt HTTP 301/302/308 | dein Server leitet weiter (z. B. http→https oder fehlender abschließender Schrägstrich); nutze die endgültige URL |
| Ping zeigt 405 | die Route akzeptiert kein POST |
| Signatur stimmt nie | der Body wurde vor dem Hashen geparst oder neu kodiert, das falsche Geheimnis wird verwendet (nach New signing secret) oder die Serveruhr geht falsch |
| keine Ereignisse für Testrechnungen | erwartet: Testrechnungen senden nie Webhooks |
Zuletzt aktualisiert: 30. Oktober 2026