Weesy Docs

Guia de integração

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

  1. Crie no seu sistema um endereço que recebe POST (por exemplo https://meusistema.com.br/weesy/avisos).
  2. Cadastre esse endereço na Weesy.
  3. Peça um aviso de teste e veja se chegou.
  4. 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.

js
// 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);
});
csharp
// 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

bash
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
      }'

3. Testar

bash
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

http
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.)

CampoO que é
idO código do aviso. É o mesmo se o aviso chegar duas vezes
tipoO que aconteceu (nfse.autorizada, nfse.rejeitada...) — lista completa na referência
ocorridoEmQuando aconteceu
empresa.cnpjDe qual empresa é a nota
dadosA 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.

  1. 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.
  2. Leia o cabeçalho webhook-signature: es256,<código da chave>,<assinatura>. Ache a chave pública que tem esse código (kid).
  3. Monte o texto assinado: <webhook-id>.<webhook-timestamp>.<corpo exatamente como chegou>.
  4. Confira a assinatura com o algoritmo ES256.
  5. Recuse se webhook-timestamp tiver mais de 5 minutos — isso impede alguém de reenviar um aviso antigo que capturou.

Os dois erros mais comuns:

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+)

js
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+)

csharp
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)

python
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 False

PHP (7.1+, extensão openssl)

php
<?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 chegaram

Regras do seu lado

RegraPor quê
Responda 2xx em até 10 segundosDepois disso, consideramos que falhou e tentamos de novo
Não processe o mesmo id duas vezesO mesmo aviso pode chegar mais de uma vez (por exemplo, se a sua resposta se perdeu na rede)
Não confie na ordem de chegadaUse ocorridoEm. Na dúvida, consulte a nota
Tipo de aviso desconhecido: responda 200 e ignoreNovos tipos podem aparecer
Redirecionamento (301, 302) conta como falhaCadastre o endereço final

Quando o seu sistema não responde

Tentamos de novo 6 vezes, sempre contando a partir da tentativa anterior:

TentativaQuando
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.


← Voltar ao início