Skip to content
RugWard
☰ Menú

RugWard Pay

Webhooks y verificación de firma

Recibe una notificación HTTPS firmada en cada cambio de estado de una factura, verifícala en curl, Node.js, Python o PHP y gestiona reintentos y duplicados con seguridad.

En esta página

Cómo funcionan los webhooks#

Cuando una factura cambia de estado, RugWard envía un POST con un cuerpo JSON a tu endpoint. Cada solicitud se firma con tu secreto de firma (signing secret), así puedes demostrar que viene de RugWard y que no fue modificada.

Configuración del webhook
El cuadro Webhook: URL del endpoint, New signing secret, Send test ping y el registro de entregas

Configuración#

  1. En el panel, introduce tu endpoint en Webhook y pulsa Save.
  2. El secreto de firma (whsec_ seguido de 40 caracteres) se muestra una sola vez. Guárdalo en tu servidor, junto a la clave API.
  3. Pulsa Send test ping. El panel muestra el estado HTTP que devolvió tu endpoint.
  4. El registro de entregas bajo el cuadro muestra cada evento, su resultado y el número de intentos.

Reglas del endpoint:

  • solo https://, en el puerto estándar (sin :8443), máximo 500 caracteres;
  • un nombre de host público: se rechazan direcciones IP, localhost y redes privadas;
  • responde en 10 segundos con cualquier estado 2xx. Las redirecciones no se siguen: una respuesta 3xx cuenta como fallo.

New signing secret reemplaza el secreto de inmediato; despliega el nuevo enseguida (las firmas con el secreto anterior dejan de valer al instante). Guardar una URL vacía desactiva los webhooks.

Eventos#

EventoSe envía cuando
invoice.pendingse vio un pago, a la espera de confirmaciones o del resto del importe
invoice.paidpagada por completo y confirmada: envía el pedido
invoice.overpaidpagada con más de un 1 % extra: envía el pedido
invoice.underpaidel bloqueo de tasa terminó con demasiado poco confirmado
invoice.latellegó un pago después del vencimiento
invoice.expiredvenció sin pago
invoice.canceledla cancelaste tú
pingel botón Send test ping del panel

Cada evento se dispara como máximo una vez por factura. Las facturas de prueba nunca envían webhooks.

La solicitud#

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 es <invoice id>:<status> y también está en el encabezado RugWard-Event-Id: úsalo para ignorar duplicados.
  • created está en segundos Unix; data.invoice es el mismo objeto que devuelve GET /api/pay/merchant/invoices/:id.

Verificar la firma#

El encabezado tiene este aspecto: RugWard-Signature: t=1793324100,v1=5f2c… (64 caracteres hexadecimales).

  1. Lee el cuerpo sin procesar de la solicitud exactamente como llegó (antes de cualquier parseo JSON).
  2. Calcula HMAC-SHA256(secret, t + "." + raw_body) y codifícalo en hexadecimal.
  3. Compáralo con v1 en tiempo constante.
  4. Rechaza la solicitud si t se aleja más de 5 minutos de tu reloj (evita repeticiones).
  5. Solo entonces parsea el JSON.

Parsear primero el JSON y volver a serializarlo cambia los bytes y rompe la firma. Verifica siempre la firma sobre el cuerpo sin procesar.

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);

Para probar tu endpoint a mano, firma tú mismo un cuerpo:

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"

Reintentos y duplicados#

Si tu endpoint agota el tiempo o no responde 2xx, RugWard reintenta: hasta 4 intentos en total, tras unos 30 segundos, 2 minutos y 8 minutos. Después, el evento se marca como fallido en el registro de entregas. Por tanto, tu manejador debe:

  • responder 2xx rápido y hacer el trabajo lento después;
  • ser idempotente: guarda RugWard-Event-Id y omite los eventos ya procesados;
  • no depender del orden: en caso de duda, vuelve a leer la factura con la API.

Los webhooks son una señal rápida, no la única. Si tu servidor estuvo caído un tiempo, lista las facturas recientes con la API y compara los estados.

Solución de problemas#

SíntomaCausa probable
"Webhook URL must be https:// with a public host name"una dirección IP, un puerto personalizado, http:// o un nombre de host interno
el ping muestra HTTP 301/302/308tu servidor redirige (por ejemplo http→https, o falta la barra final); usa la URL final
el ping muestra 405la ruta no acepta POST
la firma nunca coincideel cuerpo se parseó o recodificó antes del hash, se usa el secreto equivocado (tras New signing secret) o el reloj del servidor está mal
no hay eventos para facturas de pruebaes lo esperado: las facturas de prueba nunca envían webhooks

Última actualización: 30 de octubre de 2026

Webhooks y verificación de firma · RugWard Documentación | RugWard