Webhooks
Webhook é opcional. O caixa fica atrás da internet da loja, então o jeito recomendado de saber se pagou é a consulta aberta (GET /cobrancas/{id}?aguardar=25). O webhook serve para o seu servidor saber de tudo o que acontece — inclusive o que não parte do caixa, como um estorno feito pela loja ou uma cobrança que expirou.
Configurar
No portal: Aplicativos → (seu aplicativo) → Webhooks → Adicionar endereço.
- Só
https://, na porta 443 ou 8443, com um nome de domínio (não um IP). Endereços de rede interna são recusados. - Até 3 endereços por aplicativo e ambiente.
- Ao salvar, mandamos um evento
pingna hora. Só salva se o seu servidor responder 2xx. - O segredo (
whsec_…) aparece uma única vez. Você usa esse segredo para conferir a assinatura de cada aviso.
Eventos
| Tipo | Quando |
|---|---|
cobranca.paga |
O cliente pagou pelo app |
cobranca.cancelada |
O PDV, a loja ou uma cascata (caixa desligado) cancelou |
cobranca.expirada |
Venceu o prazo sem pagamento |
cobranca.estornada |
A loja estornou uma venda paga (pelo painel dela) |
ponto_de_venda.desativado |
O caixa foi desligado (pelo gerente, pela TribeX ou pela API) |
ponto_de_venda.reativado |
O caixa voltou a funcionar |
O que chega
POST /seu/endereco HTTP/1.1
Content-Type: application/json
User-Agent: MoedaNobre-Webhooks/1
Moeda-Nobre-Evento-Id: evt_4f1c…
Moeda-Nobre-Tipo: cobranca.paga
Moeda-Nobre-Assinatura: t=1790000000,v1=5d41402abc4b2a76b9719d911017c592…
{"id":"evt_4f1c…","tipo":"cobranca.paga","criadoEm":"2026-09-25T14:04:02-03:00","ambiente":"PRODUCAO","dados":{"cobranca":{"id":"…","status":"PAGA","valorCentavos":4590,…}}}
O objeto dentro de dados é o mesmo da API (cobranca ou pontoDeVenda). Nunca vem nome, CPF ou saldo de cliente.
Confira a assinatura (sempre)
A assinatura é um HMAC-SHA256, com o segredo inteiro (inclusive o prefixo whsec_), sobre "{t}.{corpo cru}". Use o corpo exatamente como chegou, antes de qualquer parse. Recuse se o t tiver mais de 5 minutos de diferença do seu relógio, e compare em tempo constante.
// Node.js
import crypto from "node:crypto"
export function assinaturaValida(segredo, corpoCru, cabecalho) {
const partes = Object.fromEntries(cabecalho.split(",").map((p) => p.split("=").map((s) => s.trim())))
const t = Number(partes.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
const esperado = crypto.createHmac("sha256", segredo).update(`${t}.${corpoCru}`).digest("hex")
const a = Buffer.from(esperado, "hex")
const b = Buffer.from(partes.v1 ?? "", "hex")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
# Python
import hashlib, hmac, time
def assinatura_valida(segredo: str, corpo_cru: bytes, cabecalho: str) -> bool:
partes = dict(p.strip().split("=", 1) for p in cabecalho.split(",") if "=" in p)
t = int(partes.get("t", "0"))
if abs(time.time() - t) > 300:
return False
esperado = hmac.new(segredo.encode(), f"{t}.".encode() + corpo_cru, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, partes.get("v1", ""))
// PHP
function assinaturaValida(string $segredo, string $corpoCru, string $cabecalho): bool {
$partes = [];
foreach (explode(',', $cabecalho) as $p) {
[$k, $v] = array_pad(explode('=', $p, 2), 2, '');
$partes[trim($k)] = trim($v);
}
$t = (int) ($partes['t'] ?? 0);
if (abs(time() - $t) > 300) return false;
$esperado = hash_hmac('sha256', $t . '.' . $corpoCru, $segredo);
return hash_equals($esperado, $partes['v1'] ?? '');
}
// C#
using System.Security.Cryptography;
using System.Text;
static bool AssinaturaValida(string segredo, string corpoCru, string cabecalho)
{
var partes = cabecalho.Split(',')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0].Trim(), p => p[1].Trim());
if (!partes.TryGetValue("t", out var ts) || !long.TryParse(ts, out var t)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(segredo));
var esperado = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{corpoCru}"))).ToLowerInvariant();
partes.TryGetValue("v1", out var recebido);
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(esperado), Encoding.ASCII.GetBytes(recebido ?? ""));
}
// Java 17+
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
static boolean assinaturaValida(String segredo, String corpoCru, String cabecalho) throws Exception {
String t = null, v1 = null;
for (String p : cabecalho.split(",")) {
String[] kv = p.split("=", 2);
if (kv.length < 2) continue;
if (kv[0].trim().equals("t")) t = kv[1].trim();
if (kv[0].trim().equals("v1")) v1 = kv[1].trim();
}
if (t == null || v1 == null) return false;
if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(t)) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(segredo.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String esperado = HexFormat.of().formatHex(mac.doFinal((t + "." + corpoCru).getBytes(StandardCharsets.UTF_8)));
return MessageDigest.isEqual(esperado.getBytes(StandardCharsets.US_ASCII), v1.getBytes(StandardCharsets.US_ASCII));
}
Responda rápido e processe uma vez
- Responda 2xx em até 10 segundos — antes de processar, se o processamento for demorado (enfileire do seu lado).
- O mesmo evento pode chegar mais de uma vez (retentativa, reenvio manual). Use
Moeda-Nobre-Evento-Idpara ignorar repetidos. - A ordem de chegada não é garantida. Use o
statusda cobrança (ou consulteGET /cobrancas/{id}) como a verdade. - Redirecionamento (3xx) não é seguido: conta como falha.
Retentativas e desativação automática
Se o seu servidor não responder 2xx, tentamos de novo: na hora, depois de 1 min, 5 min, 30 min, 2 h e 12 h (6 tentativas). Na 6ª falha a entrega vira FALHOU e os administradores da sua empresa recebem um e-mail. Você pode reenviar qualquer entrega pelo portal.
Se um endereço só tiver falhas por 72 horas, ele é desativado automaticamente (e-mail aos administradores). Corrija o servidor e clique em Reativar — mandamos um ping antes de religar.
Nada se perde: todos os eventos ficam em GET /eventos por 30 dias.
