Pular para o conteúdo
Crypto Merchant

Desenvolvedores

Webhooks

Os webhooks avisam o seu servidor quando algo acontece, como uma fatura ser paga, para que você não precise ficar consultando a API.

Adicionar um endpoint

No portal, abra Desenvolvedores e adicione o endereço HTTPS do seu servidor que deve receber os eventos. Cada endpoint recebe o próprio segredo de assinatura, que começa com whsec_. Um endpoint pode receber todos os eventos, inclusive os adicionados no futuro, ou só os eventos que você escolher. Use Enviar evento de teste para verificar se o seu endpoint funciona.

Eventos

invoice.created
Uma fatura foi criada.
invoice.confirming
Um pagamento foi detectado on-chain e está aguardando confirmações.
invoice.paid
A fatura está paga e confirmada. Atenda os pedidos com base neste evento.
invoice.underpaid
O pagamento confirmado ficou abaixo do valor em mais do que a sua tolerância.
invoice.expired
O tempo acabou antes de algum pagamento ser detectado.
invoice.cancelled
A fatura foi cancelada.
invoice.refunded
Moedas voltaram para o cliente: o reembolso de uma fatura paga a menos, ou um link de reembolso que você criou foi resgatado. Enviado a cada resgate, então um reembolso parcial também envia um.
deposit.pending
Um depósito na carteira fixa de um dos seus clientes foi detectado on-chain e está aguardando confirmações.
deposit.confirmed
Um depósito em carteira fixa foi confirmado e somado ao seu saldo, já descontada a taxa: credite esse cliente agora.
deposit.dropped
Um depósito detectado em carteira fixa nunca foi confirmado (falhou ou foi removido da blockchain).
payout.sent
Um saque foi enviado para a sua carteira, ou a parte de um parceiro foi enviada para a carteira dele.
payout.failed
Não foi possível enviar um saque.
swap.completed
Uma conversão para a sua moeda de reserva foi concluída.

Payload

Cada evento é enviado como um POST HTTP com um corpo JSON:

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

id é exclusivo do evento, type é um dos eventos acima e created indica quando ele aconteceu. Nos eventos de fatura, data é o objeto de fatura. Nos eventos de saque, é o objeto de saque. Nos eventos de conversão, data descreve a conversão.

Headers

Content-Type
Sempre application/json.
CM-Signature
O timestamp e a assinatura: t= o horário Unix em segundos, depois v1= a assinatura HMAC-SHA256 em hexadecimal.
CM-Event-Id
O ID do evento, igual ao id no corpo.
User-Agent
Sempre 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 a assinatura

Sempre verifique a assinatura antes de confiar em um evento. Qualquer pessoa pode enviar uma solicitação para o seu endpoint, mas só o Crypto Merchant conhece o seu segredo.

  1. 1

    Leia o corpo bruto da solicitação exatamente como chegou, antes de interpretar o JSON.

  2. 2

    Divida o header CM-Signature pelas vírgulas e leia os valores t e v1.

  3. 3

    Calcule o HMAC-SHA256 do timestamp, um ponto e o corpo bruto, usando o segredo do seu endpoint como chave, em hexadecimal minúsculo.

  4. 4

    Compare-o com v1 usando uma comparação de tempo constante.

  5. 5

    Rejeite eventos cujo timestamp tenha mais de cinco minutos, para que solicitações antigas não possam ser reenviadas.

verify-webhook.js · Verificar um webhook em 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);
}

Respostas e novas tentativas

Responda com qualquer status 2xx em até 10 segundos para confirmar que recebeu o evento. Qualquer outra resposta, ou a falta de resposta, conta como falha, e tentamos de novo depois 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

São até oito novas tentativas ao longo de cerca de dois dias. Cada tentativa é assinada de novo com um timestamp novo.

Boas práticas

  • Salve cada evento antes de responder e depois faça o trabalho a partir do que foi salvo, por exemplo em um job em segundo plano. Se não conseguir salvar, responda com um erro e enviaremos o evento novamente.
  • Os eventos podem chegar mais de uma vez e nem sempre em ordem. Use o id do evento para ignorar eventos que você já processou.
  • Antes de enviar um pedido de alto valor, você pode consultar a fatura na API para confirmar o status.
  • Mantenha o seu segredo whsec_ no seu servidor, como uma chave de API.