Saltar al contenido
Crypto Merchant

Desarrolladores

Webhooks

Los webhooks le avisan a tu servidor cuando pasa algo, por ejemplo, cuando se paga una factura, así no tienes que consultar la API una y otra vez.

Agregar un endpoint

En el portal, abre Desarrolladores y agrega la dirección HTTPS de tu servidor que debe recibir los eventos. Cada endpoint tiene su propio secreto de firma, que empieza con whsec_. Un endpoint puede recibir todos los eventos, incluidos los que se agreguen más adelante, o solo los eventos que elijas. Usa Enviar evento de prueba para comprobar que tu endpoint funciona.

Eventos

invoice.created
Se creó una factura.
invoice.confirming
Se detectó un pago en la blockchain y está esperando confirmaciones.
invoice.paid
La factura está pagada y confirmada. Despacha los pedidos con este evento.
invoice.underpaid
Al pago confirmado le falta más que tu tolerancia.
invoice.expired
El tiempo se acabó antes de que se detectara algún pago.
invoice.cancelled
La factura se canceló.
invoice.refunded
Se devolvieron monedas al cliente: un reembolso de una factura con pago incompleto, o se reclamó un enlace de reembolso que creaste. Se envía en cada reclamo, así que un reembolso parcial también lo envía.
deposit.pending
Se detectó en la blockchain un depósito en la billetera fija de uno de tus clientes y está esperando confirmaciones.
deposit.confirmed
Se confirmó un depósito en una billetera fija y se sumó a tu saldo, descontando la comisión: acredítaselo ahora a ese cliente.
deposit.dropped
Un depósito detectado en una billetera fija nunca se confirmó (falló o se eliminó de la blockchain).
payout.sent
Se envió un retiro a tu billetera, o se envió la parte de un socio a la billetera del socio.
payout.failed
No se pudo enviar un retiro.
swap.completed
Terminó una conversión a tu moneda de reserva.

Contenido del evento

Cada evento se envía como un POST HTTP con un cuerpo JSON:

JSON
{
  "id": "evt_…",
  "type": "invoice.paid",
  "created": "2026-10-28T09:41:07.000Z",
  "data": {
    "id": "inv_…",
    "object": "invoice",
    "status": "paid",
    …
  }
}

id es único para cada evento, type es uno de los eventos de arriba y created indica cuándo ocurrió. En los eventos de facturas, data es el objeto factura. En los eventos de retiros, es el objeto retiro. En los eventos de conversiones, data describe la conversión.

Encabezados

Content-Type
Siempre application/json.
CM-Signature
La marca de tiempo y la firma: t= con el tiempo Unix en segundos, y luego v1= con la firma HMAC-SHA256 en hexadecimal.
CM-Event-Id
El ID del evento, el mismo que id en el cuerpo.
User-Agent
Siempre CryptoMerchant-Webhooks/1.
HTTP
POST /webhooks/crypto-merchant HTTP/1.1
Content-Type: application/json
User-Agent: CryptoMerchant-Webhooks/1
CM-Signature: t=1792834867,v1=5f2c…
CM-Event-Id: evt_…

Verificar la firma

Verifica siempre la firma antes de confiar en un evento. Cualquiera puede enviar una solicitud a tu endpoint, pero solo Crypto Merchant conoce tu secreto.

  1. 1

    Lee el cuerpo sin procesar de la solicitud exactamente como llegó, antes de analizar el JSON.

  2. 2

    Divide el encabezado CM-Signature por las comas y lee los valores t y v1.

  3. 3

    Calcula el HMAC-SHA256 de la marca de tiempo, un punto y el cuerpo sin procesar, con el secreto de tu endpoint como clave, en hexadecimal en minúsculas.

  4. 4

    Compáralo con v1 usando una comparación de tiempo constante.

  5. 5

    Rechaza los eventos cuya marca de tiempo tenga más de cinco minutos, para que no se puedan reenviar solicitudes antiguas.

verify-webhook.js · Verificar un webhook en Node.js
import crypto from "node:crypto";

/**
 * Checks a Crypto Merchant webhook and returns the event.
 * rawBody: the request body exactly as it arrived (string or Buffer).
 */
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    String(signatureHeader ?? "")
      .split(",")
      .map((part) => part.trim().split("=")),
  );
  const t = parts.t;
  const v1 = parts.v1 ?? "";
  if (!t || !v1) throw new Error("Missing signature");

  const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : rawBody;
  const expected = crypto
    .createHmac("sha256", secret) // your endpoint secret, whsec_…
    .update(`${t}.${body}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(v1);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error("Bad signature");
  }
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) {
    throw new Error("Timestamp too old");
  }
  return JSON.parse(body);
}
server.js · Express
import express from "express";
import { verifyWebhook } from "./verify-webhook.js";
import { db } from "./db.js"; // your database, e.g. a pg Pool

// CREATE TABLE webhook_events (
//   id text PRIMARY KEY, type text NOT NULL, payload jsonb NOT NULL,
//   received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz
// );

const app = express();

// Keep the raw body: re-serialised JSON would not match the signature.
app.post(
  "/webhooks/crypto-merchant",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;
    try {
      event = verifyWebhook(req.body, req.get("CM-Signature"), process.env.CM_WEBHOOK_SECRET);
    } catch {
      return res.sendStatus(400);
    }

    try {
      // 1. Save the event before replying. A repeat delivery has the same id and is ignored.
      await db.query(
        "INSERT INTO webhook_events (id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
        [event.id, event.type, event],
      );
    } catch {
      return res.sendStatus(500); // not saved: we send it again later
    }

    // 2. It's stored, so confirm it.
    res.sendStatus(200);

    // 3. Do the work from what you saved, here or in a background job.
    //    If your server stops now, the event is still waiting in the table.
    processEvents().catch(console.error);
  },
);

async function processEvents() {
  const { rows } = await db.query(
    "SELECT payload FROM webhook_events WHERE processed_at IS NULL ORDER BY received_at",
  );
  for (const { payload: event } of rows) {
    if (event.type === "invoice.paid") {
      // Mark the order in event.data.order_id as paid. Doing it twice must change nothing.
    }
    await db.query("UPDATE webhook_events SET processed_at = now() WHERE id = $1", [event.id]);
  }
}
verify_webhook.py · Python
import hashlib, hmac, json, time

def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> dict:
    """Checks a Crypto Merchant webhook and returns the event. raw_body: the request body exactly as it arrived."""
    parts = dict(part.strip().split("=", 1) for part in (signature_header or "").split(",") if "=" in part)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        raise ValueError("Missing signature")
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()  # your endpoint secret, whsec_…
    if not hmac.compare_digest(expected, v1):
        raise ValueError("Bad signature")
    if abs(time.time() - int(t)) > tolerance_sec:
        raise ValueError("Timestamp too old")
    return json.loads(raw_body)
verify_webhook.php · PHP
<?php
// Checks a Crypto Merchant webhook and returns the event.
// Read the raw body with file_get_contents('php://input') and the header with $_SERVER['HTTP_CM_SIGNATURE'].
function verify_webhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSec = 300): array {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        $parts[$key] = $value;
    }
    $t = $parts['t'] ?? '';
    $v1 = $parts['v1'] ?? '';
    if ($t === '' || $v1 === '') {
        throw new RuntimeException('Missing signature');
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); // your endpoint secret, whsec_…
    if (!hash_equals($expected, $v1)) {
        throw new RuntimeException('Bad signature');
    }
    if (abs(time() - (int) $t) > $toleranceSec) {
        throw new RuntimeException('Timestamp too old');
    }
    return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}

Respuestas y reintentos

Responde con cualquier estado 2xx en menos de 10 segundos para confirmar que recibiste el evento. Cualquier otra respuesta, o la falta de respuesta, cuenta como un fallo, y volvemos a intentarlo después de:

  1. 1 minuto
  2. 5 minutos
  3. 15 minutos
  4. 1 hora
  5. 3 horas
  6. 6 horas
  7. 12 horas
  8. 24 horas

Son hasta ocho reintentos a lo largo de unos dos días. Cada intento se vuelve a firmar con una marca de tiempo nueva.

Buenas prácticas

  • Guarda cada evento antes de responder y luego haz el trabajo a partir de lo que guardaste, por ejemplo, en una tarea en segundo plano. Si falla el guardado, responde con un error y volveremos a enviar el evento.
  • Los eventos pueden llegar más de una vez y no siempre en orden. Usa el id del evento para omitir los eventos que ya procesaste.
  • Antes de enviar un pedido valioso, puedes obtener la factura desde la API para volver a comprobar su estado.
  • Guarda tu secreto whsec_ en tu servidor, igual que una clave de API.