Skip to content
RugWard
☰ Menu

RugWard Pay

Webhooks and signature verification

Get a signed HTTPS notification on every invoice status change, verify it in curl, Node.js, Python or PHP, and handle retries and duplicates safely.

On this page

How webhooks work#

When an invoice changes status, RugWard sends a POST with a JSON body to your endpoint. Each request is signed with your signing secret, so you can prove it came from RugWard and was not changed.

Webhook settings
The Webhook box: endpoint URL, New signing secret, Send test ping and the delivery log

Set up#

  1. In the dashboard, enter your endpoint in Webhook and click Save.
  2. The signing secret (whsec_ followed by 40 characters) is shown once. Store it on your server, next to the API key.
  3. Click Send test ping. The dashboard shows the HTTP status your endpoint returned.
  4. The delivery log under the box shows each event, its result and the number of attempts.

Endpoint rules:

  • https:// only, on the standard port (no :8443), max 500 characters;
  • a public host name: IP addresses, localhost and private networks are refused;
  • answer within 10 seconds with any 2xx status. Redirects are not followed: a 3xx answer counts as a failure.

New signing secret replaces the secret immediately; deploy the new one right away (signatures made with the old secret stop at once). Saving an empty URL turns webhooks off.

Events#

EventSent when
invoice.pendinga payment was seen, waiting for confirmations or for the rest of the amount
invoice.paidfully paid and confirmed: ship
invoice.overpaidpaid with more than 1% extra: ship
invoice.underpaidthe rate lock ended with too little confirmed
invoice.latea payment arrived after expiry
invoice.expiredexpired without payment
invoice.canceledyou canceled it
pingthe dashboard's Send test ping button

Every event fires at most once per invoice. Test invoices never send webhooks.

The request#

Webhook request (what your endpoint receives)
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)
Body: invoice.paid
{
  "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…" }]
    }
  }
}
Body: test ping (dashboard button)
{ "id": "ping:1793324000000", "type": "ping", "created": 1793324000, "data": {} }
  • id is <invoice id>:<status> and is also in the RugWard-Event-Id header: use it to ignore duplicates.
  • created is in Unix seconds; data.invoice is the same object as GET /api/pay/merchant/invoices/:id returns.

Verify the signature#

The header looks like RugWard-Signature: t=1793324100,v1=5f2c… (64 hex characters).

  1. Read the raw request body exactly as received (before any JSON parsing).
  2. Compute HMAC-SHA256(secret, t + "." + raw_body) and hex-encode it.
  3. Compare it with v1 in constant time.
  4. Reject the request if t is more than 5 minutes away from your clock (stops replays).
  5. Only then parse the JSON.

Parsing the JSON first and re-serializing it changes the bytes and breaks the signature. Always sign-check the raw body.

Node.js (Express): verify, then process
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);
Python (Flask)
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 8
<?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);

To test your endpoint by hand, sign a body yourself:

Shell (openssl): compute a signature to test your endpoint
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"

Retries and duplicates#

If your endpoint times out or does not answer 2xx, RugWard retries: up to 4 attempts in total, after about 30 seconds, 2 minutes and 8 minutes. After that the event is marked failed in the delivery log. Your handler should therefore:

  • answer 2xx quickly and do slow work afterwards;
  • be idempotent: store RugWard-Event-Id and skip events you have already processed;
  • not depend on order: re-read the invoice with the API when in doubt.

Webhooks are a fast signal, not the only one. If your server was down for a while, list recent invoices with the API and compare statuses.

Troubleshooting#

SymptomLikely cause
"Webhook URL must be https:// with a public host name"an IP address, a custom port, http:// or an internal host name
ping shows HTTP 301/302/308your server redirects (for example http→https, or a missing trailing slash); use the final URL
ping shows 405the route does not accept POST
signature never matchesthe body was parsed or re-encoded before hashing, the wrong secret is used (after New signing secret), or the server clock is wrong
no events for test invoicesexpected: test invoices never send webhooks

Last updated: October 30, 2026

Webhooks and signature verification · RugWard Documentation | RugWard