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:
{
"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, depoisv1=a assinatura HMAC-SHA256 em hexadecimal. CM-Event-Id- O ID do evento, igual ao
idno corpo. User-Agent- Sempre
CryptoMerchant-Webhooks/1.
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
Leia o corpo bruto da solicitação exatamente como chegou, antes de interpretar o JSON.
- 2
Divida o header
CM-Signaturepelas vírgulas e leia os valorestev1. - 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
Compare-o com
v1usando uma comparação de tempo constante. - 5
Rejeite eventos cujo timestamp tenha mais de cinco minutos, para que solicitações antigas não possam ser reenviadas.
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);
}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]);
}
}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)<?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 minuto
- 5 minutos
- 15 minutos
- 1 hora
- 3 horas
- 6 horas
- 12 horas
- 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
iddo 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.