Webhooks
O que é
Quando você emite uma nota, a prefeitura leva de alguns segundos a alguns minutos para autorizar. Em vez de o seu sistema ficar perguntando "já saiu?", a Weesy avisa: assim que algo acontece com a nota, fazemos uma chamada POST para um endereço do seu sistema. Esse aviso é o webhook.
Como funciona, em 4 passos
- Crie no seu sistema um endereço que recebe
POST(por exemplohttps://meusistema.com.br/weesy/avisos). - Cadastre esse endereço na Weesy.
- Peça um aviso de teste e veja se chegou.
- Confira a assinatura de cada aviso — é o que garante que ele veio mesmo da Weesy.
1. Criar o endereço no seu sistema
A regra de ouro: responda 200 rápido e processe depois. Você tem 10 segundos. Se o seu sistema for gravar no banco, gerar boleto ou mandar e-mail, guarde o aviso numa fila e responda logo.
// Node.js + Express
import express from 'express';
const app = express();
// Importante: leia o corpo como bytes, sem converter para JSON antes de conferir a assinatura.
app.post('/weesy/avisos', express.raw({ type: 'application/json' }), (req, res) => {
if (!assinaturaValida(req.headers, req.body)) return res.sendStatus(401);
filaDeAvisos.push(JSON.parse(req.body)); // processa depois
res.sendStatus(200);
});// C# — ASP.NET Core (minimal API)
app.MapPost("/weesy/avisos", async (HttpRequest req) =>
{
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
byte[] corpo = ms.ToArray(); // os bytes exatamente como chegaram
if (!Webhook.AssinaturaValida(req.Headers["webhook-id"]!, req.Headers["webhook-timestamp"]!,
corpo, req.Headers["webhook-signature"]!, jwks))
return Results.Unauthorized();
fila.Enqueue(corpo); // processa depois
return Results.Ok();
});2. Cadastrar o endereço na Weesy
curl -X PUT https://apifiscal.weesy.com.br/v1/empresas/21664333000195/webhook \
-H "Authorization: Bearer $WEESY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://meusistema.com.br/weesy/avisos",
"eventos": ["nfse.autorizada", "nfse.rejeitada", "nfse.cancelada"],
"ativo": true
}'- O endereço precisa ser
https, na porta padrão, com nome de domínio (não aceitamos IP). eventosvazio ([]) = receber todos os avisos.
3. Testar
curl -X POST https://apifiscal.weesy.com.br/v1/webhook/teste \
-H "Authorization: Bearer $WEESY_TOKEN"Mandamos um aviso do tipo teste para o endereço cadastrado. Se não chegou, veja o que aconteceu em Listar os webhooks enviados (GET /v1/webhooks/eventos): lá aparece cada tentativa com a resposta que o seu sistema deu.
O que chega no seu sistema
POST /weesy/avisos HTTP/1.1
Content-Type: application/json
webhook-id: evt_01K8G2C4R7V9X3Z5B8D1F6H0JM
webhook-timestamp: 1761481690
webhook-signature: es256,whk_2026a,3q2-7wJ...
{
"id": "evt_01K8G2C4R7V9X3Z5B8D1F6H0JM",
"tipo": "nfse.autorizada",
"versao": "v1",
"ocorridoEm": "2026-10-26T12:28:10Z",
"empresa": { "cnpj": "21664333000195" },
"dados": {
"id": "6ade6278e8340a492a46b564",
"idIntegracao": "pedido-10432",
"status": "autorizada",
"documento": { "numero": "947", "codigoVerificacao": "H45N1MFG0" },
"links": { "pdf": "/v1/nfse/6ade6278e8340a492a46b564/pdf", "xml": "/v1/nfse/6ade6278e8340a492a46b564/xml" }
}
}(Encurtado: em dados vem a nota inteira, igual à de Consultar NFS-e.)
| Campo | O que é |
|---|---|
id | O código do aviso. É o mesmo se o aviso chegar duas vezes |
tipo | O que aconteceu (nfse.autorizada, nfse.rejeitada...) — lista completa na referência |
ocorridoEm | Quando aconteceu |
empresa.cnpj | De qual empresa é a nota |
dados | A nota, como estava no momento do aviso |
4. Conferir a assinatura
Qualquer pessoa que descubra o seu endereço pode mandar um POST para ele. A assinatura prova que o aviso veio da Weesy e não foi alterado no caminho. Confira sempre.
Como funciona
A Weesy assina cada aviso com uma chave secreta que só ela tem. Você confere com a chave pública correspondente, que qualquer um pode baixar — ela serve para conferir, não para assinar.
- Baixe as chaves públicas:
GET https://apifiscal.weesy.com.br/v1/webhooks/chaves(não precisa de token). Guarde em cache; baixe de novo só quando aparecer uma chave que você não conhece. - Leia o cabeçalho
webhook-signature:es256,<código da chave>,<assinatura>. Ache a chave pública que tem esse código (kid). - Monte o texto assinado:
<webhook-id>.<webhook-timestamp>.<corpo exatamente como chegou>. - Confira a assinatura com o algoritmo ES256.
- Recuse se
webhook-timestamptiver mais de 5 minutos — isso impede alguém de reenviar um aviso antigo que capturou.
Os dois erros mais comuns:
- Converter o corpo para JSON antes de conferir. Ao converter e voltar para texto, espaços e ordem dos campos mudam, e a assinatura não bate mais. Use os bytes exatamente como chegaram.
- Usar uma biblioteca pronta de "Standard Webhooks". Os nomes dos cabeçalhos são os mesmos, mas essas bibliotecas conferem outro algoritmo (HMAC com senha compartilhada). Use os exemplos abaixo.
Durante uma troca de chave, o cabeçalho pode trazer duas assinaturas separadas por espaço. Basta uma estar certa — os exemplos já tratam isso.
Node.js (18+)
import crypto from 'node:crypto';
// jwks = resposta de GET /v1/webhooks/chaves (guarde em cache)
export function assinaturaValida(headers, corpo, jwks) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const assinaturas = String(headers['webhook-signature'] ?? '');
const agora = Math.floor(Date.now() / 1000);
if (Math.abs(agora - Number(timestamp)) > 300) return false; // mais de 5 minutos
const conteudo = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), Buffer.from(corpo)]);
for (const item of assinaturas.split(' ')) {
const [algoritmo, kid, assinatura] = item.split(',');
if (algoritmo !== 'es256') continue;
const jwk = jwks.keys.find((k) => k.kid === kid);
if (!jwk) continue;
const chave = crypto.createPublicKey({ key: jwk, format: 'jwk' });
const ok = crypto.verify('sha256', conteudo,
{ key: chave, dsaEncoding: 'ieee-p1363' }, Buffer.from(assinatura, 'base64url'));
if (ok) return true;
}
return false;
}C# (.NET 9+)
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public static class Webhook
{
// jwks = resposta de GET /v1/webhooks/chaves (guarde em cache)
public static bool AssinaturaValida(string id, string timestamp, byte[] corpo,
string assinaturas, JsonElement jwks)
{
long agora = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (!long.TryParse(timestamp, out long ts) || Math.Abs(agora - ts) > 300) return false;
byte[] conteudo = [.. Encoding.UTF8.GetBytes($"{id}.{timestamp}."), .. corpo];
foreach (string item in assinaturas.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
string[] partes = item.Split(','); // es256, código da chave, assinatura
if (partes.Length != 3 || partes[0] != "es256") continue;
foreach (JsonElement jwk in jwks.GetProperty("keys").EnumerateArray())
{
if (jwk.GetProperty("kid").GetString() != partes[1]) continue;
using ECDsa ecdsa = ECDsa.Create(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint
{
X = Base64Url.DecodeFromChars(jwk.GetProperty("x").GetString()),
Y = Base64Url.DecodeFromChars(jwk.GetProperty("y").GetString()),
},
});
if (ecdsa.VerifyData(conteudo, Base64Url.DecodeFromChars(partes[2]),
HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
return true;
}
}
return false;
}
}Python (3.9+, pacote cryptography)
import base64, time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils
def _b64url(texto: str) -> bytes:
return base64.urlsafe_b64decode(texto + "=" * (-len(texto) % 4))
# jwks = resposta de GET /v1/webhooks/chaves (guarde em cache)
def assinatura_valida(headers: dict, corpo: bytes, jwks: dict) -> bool:
id_ = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300: # mais de 5 minutos
return False
conteudo = f"{id_}.{timestamp}.".encode() + corpo
for item in headers.get("webhook-signature", "").split():
partes = item.split(",")
if len(partes) != 3 or partes[0] != "es256":
continue
for jwk in jwks["keys"]:
if jwk["kid"] != partes[1]:
continue
x = int.from_bytes(_b64url(jwk["x"]), "big")
y = int.from_bytes(_b64url(jwk["y"]), "big")
chave = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
bruta = _b64url(partes[2])
r, s = int.from_bytes(bruta[:32], "big"), int.from_bytes(bruta[32:], "big")
try:
chave.verify(utils.encode_dss_signature(r, s), conteudo, ec.ECDSA(hashes.SHA256()))
return True
except InvalidSignature:
pass
return FalsePHP (7.1+, extensão openssl)
<?php
function b64url(string $texto): string {
return base64_decode(strtr($texto, '-_', '+/'));
}
// O openssl do PHP pede a chave em PEM e a assinatura em DER — estas duas funções convertem.
function jwkParaPem(array $jwk): string {
$der = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200')
. "\x04" . b64url($jwk['x']) . b64url($jwk['y']);
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
function assinaturaParaDer(string $bruta): string {
$inteiro = function (string $v): string {
$v = ltrim($v, "\x00");
if ($v === '' || ord($v[0]) > 0x7f) { $v = "\x00" . $v; }
return "\x02" . chr(strlen($v)) . $v;
};
$seq = $inteiro(substr($bruta, 0, 32)) . $inteiro(substr($bruta, 32, 32));
return "\x30" . chr(strlen($seq)) . $seq;
}
// $jwks = resposta de GET /v1/webhooks/chaves, decodificada com json_decode(..., true)
function assinaturaValida(string $id, string $timestamp, string $corpo,
string $assinaturas, array $jwks): bool {
if (abs(time() - (int) $timestamp) > 300) { return false; } // mais de 5 minutos
$conteudo = "$id.$timestamp." . $corpo;
foreach (preg_split('/\s+/', trim($assinaturas)) as $item) {
$partes = explode(',', $item);
if (count($partes) !== 3 || $partes[0] !== 'es256') { continue; }
foreach ($jwks['keys'] as $jwk) {
if ($jwk['kid'] !== $partes[1]) { continue; }
$der = assinaturaParaDer(b64url($partes[2]));
if (openssl_verify($conteudo, $der, jwkParaPem($jwk), OPENSSL_ALGO_SHA256) === 1) {
return true;
}
}
}
return false;
}
// Uso: $corpo = file_get_contents('php://input'); // os bytes como chegaramRegras do seu lado
| Regra | Por quê |
|---|---|
Responda 2xx em até 10 segundos | Depois disso, consideramos que falhou e tentamos de novo |
Não processe o mesmo id duas vezes | O mesmo aviso pode chegar mais de uma vez (por exemplo, se a sua resposta se perdeu na rede) |
| Não confie na ordem de chegada | Use ocorridoEm. Na dúvida, consulte a nota |
Tipo de aviso desconhecido: responda 200 e ignore | Novos tipos podem aparecer |
Redirecionamento (301, 302) conta como falha | Cadastre o endereço final |
Quando o seu sistema não responde
Tentamos de novo 6 vezes, sempre contando a partir da tentativa anterior:
| Tentativa | Quando |
|---|---|
| 1ª | Na hora |
| 2ª | 1 minuto depois da 1ª |
| 3ª | 5 minutos depois da 2ª |
| 4ª | 30 minutos depois da 3ª |
| 5ª | 2 horas depois da 4ª |
| 6ª | 6 horas depois da 5ª |
| 7ª | 24 horas depois da 6ª |
Se as 7 falharem (cerca de 33 horas no total), o aviso fica como esgotado em Listar os webhooks enviados. Quando o seu sistema voltar, use Reenviar um webhook (POST /v1/webhooks/eventos/{id}/reenvio): mandamos o mesmo aviso, com o mesmo id.
Perguntas frequentes
Preciso ter webhook? Não. Você pode consultar a nota (GET /v1/nfse/{id}) de tempos em tempos. O webhook só evita ficar perguntando.
Posso usar o mesmo endereço para várias empresas? Pode. O campo empresa.cnpj diz de qual é o aviso.
O endereço precisa estar na internet? Precisa. Para testar no seu computador, use uma ferramenta que cria um endereço público temporário (ngrok, Cloudflare Tunnel) e cadastre esse endereço.
Por que a assinatura não bate? Em 9 de cada 10 casos, o corpo foi convertido para JSON antes da conferência. Confira usando os bytes exatamente como chegaram.