Conceitos

Webhooks e assinaturas

Como receber um aviso na hora em que algo acontece e como ter a certeza de que vem do Mucanda: a assinatura HMAC-SHA256.

Um webhook é o Mucanda a bater à porta do seu sistema. Em vez de perguntar de tempos a tempos «já houve alguma factura nova?», diz ao Mucanda um endereço seu; quando um documento é emitido, a AGT o valida ou um pagamento é registado, o Mucanda faz um pedido POST a esse endereço com os pormenores do que aconteceu.

Como o endereço do seu sistema é público, qualquer pessoa o podia chamar a fingir que é o Mucanda. Por isso cada aviso vai assinado: leva uma assinatura que só quem conhece o segredo do webhook consegue calcular. O seu sistema recalcula-a e só confia no aviso se bater certo. Esta página explica como.

#Os passos

  1. 1
    Crie um endereço que receba POST

    Um endereço HTTPS público do seu sistema, por exemplo https://loja.exemplo.ao/webhooks/mucanda. Tem de responder em menos de 10 segundos.

  2. 2
    Registe-o no Mucanda

    Em Definições › Webhooks, escolha o endereço e os eventos que quer receber. O Mucanda mostra o segredo (whsec_…) uma única vez: guarde-o no seu servidor, nunca no código nem numa aplicação de telemóvel.

  3. 3
    Verifique a assinatura de cada aviso

    Recalcule-a com o segredo e compare-a com a que vem no cabeçalho Mucanda-Signature. Se não bater, responda 400 e ignore o aviso.

  4. 4
    Responda depressa com 2xx

    Grave o aviso, responda 200 e processe-o depois. Qualquer resposta que não seja 2xx (ou nenhuma, em 10 segundos) conta como falha e o aviso é repetido.

  5. 5
    Use «Testar»

    No ecrã dos webhooks, Testar envia já um aviso webhook.test assinado: serve para confirmar que o seu servidor o recebe e verifica.

#O que se recebe

Cada aviso é um POST com corpo JSON (Content-Type: application/json) e estes cabeçalhos:

CabeçalhoPara que serve
Mucanda-SignatureA assinatura: t=<hora>,v1=<assinatura>. Ver abaixo.
Mucanda-EventO tipo do evento (document.created…).
Mucanda-Event-IdO identificador do evento (evt_…). É o que usa para ignorar repetidos.
Mucanda-DeliveryO identificador desta entrega ao seu endereço.
Mucanda-AttemptO número da tentativa: 1 na primeira, 2 na primeira repetição…
User-AgentMucanda-Webhooks/1.0.
O corpo de um aviso (document.created)
{
  "id": "evt_0199f3c2a7b84e5d9c1f2a3b4c5d6e7f",
  "type": "document.created",
  "created": "2026-10-16T09:15:02.481Z",
  "companyId": "5b0d3b52-6f3a-4a6e-8a0e-2c8d9f1e7a10",
  "data": {
    "documentoId": "01a12f68-f692-7d4d-8f54-377d674d730a",
    "numero": "FR A2026/42",
    "tipo": "FR",
    "tipoDescricao": "Factura/Recibo",
    "dataEmissao": "2026-10-16T09:15:02.000Z",
    "clienteId": "9c1f6c1e-3f0b-4f43-9a52-0f4c4b5a1d11",
    "lojaId": "3d8a8a3e-5b8c-4d0e-9f77-6a1a0f2e4b90",
    "total": 14820.00,
    "documentoOrigemId": null
  }
}

O aviso leva o mínimo para identificar o que aconteceu; o resto vai buscá-lo à API (por exemplo, GET /api/documentos/{id}). Isto mantém os avisos pequenos e evita que dados fiscais andem por aí sem necessidade.

#Os eventos

EventoQuando aconteceO que traz em data
document.createdUm documento foi emitido (factura, factura/recibo, nota de crédito, guia, pró-forma…).documentoId, numero, tipo, tipoDescricao, dataEmissao, clienteId, lojaId, total, documentoOrigemId
document.agt_validatedA AGT aceitou o documento na facturação electrónica (inclui «aceite com penalização»).documentoId, numero, tipo, penalizado (verdadeiro se foi aceite com penalização), validadoEm
document.agt_rejectedA AGT recusou o documento. Os erros vão no evento, tal como a AGT os devolveu.documentoId, numero, tipo, validadoEm, erros
document.cancelledUma nota de crédito anulou por inteiro o documento de origem.documentoId, numero, notaDeCreditoId, notaDeCreditoNumero, dataAnulacao
payment.receivedFoi registado um pagamento e emitido o recibo.reciboId, numero, valor, clienteId, lojaId, data
customer.createdFoi criado um cliente, pela API ou pelo painel.clienteId, nome, numeroContribuinte

Cada endereço recebe só os eventos que subscreveu. Os nomes dos eventos nunca mudam; podem aparecer eventos novos, que ninguém recebe sem os subscrever. Ignore os campos que não conhece: a API só cresce.

#A assinatura (HMAC-SHA256)

O HMAC é uma «impressão digital com segredo»: uma função que, a partir de um texto e de um segredo, calcula um valor. Quem não tem o segredo não consegue calcular o valor certo, e qualquer alteração ao texto muda-o por completo. O Mucanda calcula-o com o segredo do seu webhook e envia-o no cabeçalho; o seu sistema calcula-o outra vez com o mesmo segredo. Se os dois valores forem iguais, o aviso veio do Mucanda e não foi alterado pelo caminho.

O cabeçalho
Mucanda-Signature: t=1760606102,v1=ff2db01184b35eba032ba91f5204c2ff8855c9b9cd95929205357721e96744c5
ParteSentido
tA hora do envio, em segundos desde 1970 (hora Unix).
v1A assinatura: o HMAC-SHA256 em hexadecimal minúsculo.
O texto assinadot + . + o corpo exacto recebido: por exemplo 1760606102.{"id":"evt_…"}.
A chaveO segredo do webhook inteiro, com o prefixo (whsec_…), como texto UTF-8.

Verificar, passo a passo

  1. Leia o cabeçalho Mucanda-Signature e separe t e as assinaturas v1 (pode haver mais do que uma: ver «Mudar o segredo»).
  2. Recuse o aviso se t diferir da hora do seu servidor em mais de 5 minutos: protege contra alguém reenviar um aviso antigo que apanhou.
  3. Calcule o HMAC-SHA256 de t + "." + corpo, com o segredo, em hexadecimal minúsculo.
  4. Compare com cada v1 em tempo constante (nunca com ==). Se alguma for igual, o aviso é autêntico.

Um exemplo para testar o seu código

Antes de ligar o seu código ao Mucanda, confirme que calcula o mesmo valor que este exemplo (é um segredo de exemplo, sem valor nenhum). O texto assinado é a hora, um ponto e o corpo exacto, numa só linha.

Segredo, hora, corpo e assinatura de exemplo
segredo: whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
t:       1760606102
corpo:   {"id":"evt_0199f3c2a7b84e5d9c1f2a3b4c5d6e7f","type":"document.created","created":"2026-10-16T09:15:02.481Z","companyId":"5b0d3b52-6f3a-4a6e-8a0e-2c8d9f1e7a10","data":{"documentoId":"01a12f68-f692-7d4d-8f54-377d674d730a","numero":"FR A2026/42","tipo":"FR","total":14820.00}}

v1 = HMAC-SHA256(segredo, "1760606102." + corpo)
v1 = ff2db01184b35eba032ba91f5204c2ff8855c9b9cd95929205357721e96744c5
Node.js (Express)
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'

const SEGREDO = process.env.MUCANDA_WEBHOOK_SECRET // whsec_…
const app = express()

// O corpo em bruto (Buffer), não o JSON já interpretado.
app.post('/webhooks/mucanda', express.raw({ type: 'application/json' }), (req, res) => {
  const cabecalho = req.get('Mucanda-Signature') ?? ''
  const partes = cabecalho.split(',').map((p) => p.split('='))
  const t = partes.find(([k]) => k === 't')?.[1]
  const assinaturas = partes.filter(([k]) => k === 'v1').map(([, v]) => v)

  const idadeEmSegundos = Math.abs(Date.now() / 1000 - Number(t))
  if (!t || assinaturas.length === 0 || idadeEmSegundos > 300) return res.sendStatus(400)

  const esperada = createHmac('sha256', SEGREDO).update(`${t}.${req.body.toString('utf8')}`).digest('hex')
  const valida = assinaturas.some((a) => a.length === esperada.length && timingSafeEqual(Buffer.from(a), Buffer.from(esperada)))
  if (!valida) return res.sendStatus(400)

  const evento = JSON.parse(req.body.toString('utf8'))
  guardarParaProcessar(evento) // grave e responda já; processe depois
  res.sendStatus(200)
})
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort

SEGREDO = os.environ['MUCANDA_WEBHOOK_SECRET'].encode()  # whsec_…
app = Flask(__name__)

@app.post('/webhooks/mucanda')
def webhook():
    corpo = request.get_data()  # bytes em bruto
    partes = [p.split('=', 1) for p in request.headers.get('Mucanda-Signature', '').split(',') if '=' in p]
    t = next((v for k, v in partes if k == 't'), None)
    assinaturas = [v for k, v in partes if k == 'v1']
    if not t or not assinaturas or abs(time.time() - int(t)) > 300:
        abort(400)

    esperada = hmac.new(SEGREDO, t.encode() + b'.' + corpo, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(a, esperada) for a in assinaturas):
        abort(400)

    guardar_para_processar(request.get_json())  # grave e responda já
    return '', 200
PHP
<?php
$segredo = getenv('MUCANDA_WEBHOOK_SECRET'); // whsec_…
$corpo = file_get_contents('php://input');   // bytes em bruto

$t = null;
$assinaturas = [];
foreach (explode(',', $_SERVER['HTTP_MUCANDA_SIGNATURE'] ?? '') as $parte) {
    [$k, $v] = array_pad(explode('=', $parte, 2), 2, '');
    if ($k === 't') $t = $v;
    if ($k === 'v1') $assinaturas[] = $v;
}
if ($t === null || !$assinaturas || abs(time() - (int) $t) > 300) { http_response_code(400); exit; }

$esperada = hash_hmac('sha256', $t . '.' . $corpo, $segredo);
$valida = false;
foreach ($assinaturas as $a) { if (hash_equals($esperada, $a)) $valida = true; }
if (!$valida) { http_response_code(400); exit; }

guardarParaProcessar(json_decode($corpo, true)); // grave e responda já
http_response_code(200);
C# (ASP.NET Core, Minimal API)
app.MapPost("/webhooks/mucanda", async (HttpRequest pedido) =>
{
    using var leitor = new StreamReader(pedido.Body, Encoding.UTF8);
    var corpo = await leitor.ReadToEndAsync(); // texto em bruto, antes de interpretar o JSON

    long? t = null;
    var assinaturas = new List<string>();
    foreach (var parte in pedido.Headers["Mucanda-Signature"].ToString().Split(','))
    {
        var i = parte.IndexOf('=');
        if (i <= 0) continue;
        var chave = parte[..i].Trim();
        var valor = parte[(i + 1)..].Trim();
        if (chave == "t" && long.TryParse(valor, out var h)) t = h;
        else if (chave == "v1") assinaturas.Add(valor);
    }
    if (t is null || assinaturas.Count == 0 || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t.Value) > 300)
        return Results.BadRequest();

    var segredo = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("MUCANDA_WEBHOOK_SECRET")!); // whsec_…
    var esperada = Convert.ToHexString(HMACSHA256.HashData(segredo, Encoding.UTF8.GetBytes($"{t}.{corpo}"))).ToLowerInvariant();
    var valida = assinaturas.Any(a => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(esperada)));
    if (!valida) return Results.BadRequest();

    GuardarParaProcessar(corpo); // grave e responda já
    return Results.Ok();
});

#Se o seu servidor falhar: as repetições

Um aviso conta como entregue quando o seu servidor responde 2xx em 10 segundos. Qualquer outra coisa (um erro 4xx ou 5xx, uma resposta lenta, o servidor em baixo, um redireccionamento) é uma falha, e o aviso é repetido com intervalos crescentes:

TentativaQuando
1.ªDe imediato.
2.ª1 minuto depois da falha.
3.ª5 minutos depois.
4.ª30 minutos depois.
5.ª2 horas depois.
6.ª6 horas depois.
7.ª12 horas depois.
8.ª e 9.ª24 horas depois, cada uma.

No total, o Mucanda insiste durante cerca de 3 dias. Esgotadas as tentativas, a entrega fica como «Falhou» no ecrã dos webhooks, onde pode reenviá-la à mão depois de corrigir o servidor. Os redireccionamentos (301, 302…) não são seguidos: responda directamente no endereço registado.

#O mesmo aviso pode chegar mais do que uma vez

Uma repetição pode chegar mesmo depois de o seu servidor ter processado o aviso (por exemplo, se a resposta 200 se perdeu pelo caminho). O Mucanda garante que nenhum aviso se perde, não que chega uma só vez. Por isso o seu sistema tem de ser idempotente: guarde o Mucanda-Event-Id (evt_…) de cada aviso que já tratou e, se voltar a chegar, responda 200 e ignore-o. O identificador é o mesmo em todas as repetições.

#Recuperar o que perdeu: GET /api/eventos

Todos os eventos dos últimos 30 dias ficam guardados e podem ler-se, mesmo sem ter webhooks, com GET /api/eventos (também com uma chave de API que tenha a permissão Webhooks.Ler). É a rede de segurança: se o seu servidor esteve em baixo mais de 3 dias, ou criou o webhook depois, lê aqui o que falta.

ParâmetroSentido
tipoSó os eventos deste tipo (document.created…).
desdeSó os eventos criados a partir deste instante (ISO 8601, UTC).
tamanho e cursorPaginação, como nas outras listagens (ver Paginação).
A resposta de GET /api/eventos?tipo=document.created&tamanho=1
{
  "itens": [
    {
      "id": "evt_0199f3c2a7b84e5d9c1f2a3b4c5d6e7f",
      "tipo": "document.created",
      "criadoEmUtc": "2026-10-16T09:15:02.481Z",
      "evento": {
        "id": "evt_0199f3c2a7b84e5d9c1f2a3b4c5d6e7f",
        "type": "document.created",
        "created": "2026-10-16T09:15:02.481Z",
        "companyId": "5b0d3b52-6f3a-4a6e-8a0e-2c8d9f1e7a10",
        "data": { "documentoId": "01a12f68-f692-7d4d-8f54-377d674d730a", "numero": "FR A2026/42", "total": 14820.00 }
      }
    }
  ],
  "proximoCursor": null
}

O campo evento é exactamente o corpo que o webhook enviaria: o mesmo código que trata os avisos trata os eventos lidos.

#Mudar o segredo sem perder avisos

Se o segredo foi exposto, ou por rotina, rode-o no ecrã dos webhooks. O segredo novo é mostrado uma vez. Durante as 24 horas seguintes, cada aviso leva duas assinaturas (t=…,v1=<com o novo>,v1=<com o antigo>): o seu servidor aceita qualquer das duas, e pode trocar o segredo à vontade, sem uma janela em que os avisos falhem. Passadas 24 horas, só a nova vale. Os exemplos acima já tratam várias assinaturas v1.

#Segurança

  • Só HTTPS. Os eventos levam dados fiscais. Um endereço http://, com utilizador e palavra-passe na ligação ou com fragmento (#) é recusado ao registar.
  • Só endereços públicos. O Mucanda recusa localhost, endereços de redes internas (10.x, 192.168.x, 172.16–31.x, 169.254.x…) e nomes que resolvam para elas, no registo e outra vez a cada envio: um webhook não pode ser usado para fazer o Mucanda chamar o seu interior (um ataque chamado SSRF). Para desenvolver no seu computador, use um túnel público (por exemplo ngrok).
  • O segredo só existe no seu servidor. O Mucanda guarda-o cifrado e só o mostra ao criar e ao rodar. Não o ponha em código-fonte, em aplicações móveis nem em registos (logs).
  • Verifique sempre a assinatura, mesmo que o endereço seja secreto: um endereço descobre-se. Sem verificação, qualquer pessoa pode mandar-lhe avisos falsos.
  • Compare em tempo constante e valide a hora (5 minutos), como nos exemplos.
  • Responda depressa. Trabalho demorado (emitir, enviar e-mails…) faz-se depois de responder 200, numa fila sua.

#Limites

AssuntoLimite
Webhooks por empresaAté 10 endereços.
Tempo para responder10 segundos.
Tentativas1 + 8 repetições, durante cerca de 3 dias.
Tolerância de relógio5 minutos na verificação da hora.
Eventos guardados30 dias, em GET /api/eventos.
Histórico das entregasVisível no ecrã de cada webhook.

#Perguntas frequentes

A assinatura nunca bate certo. Porquê?

  • O corpo foi interpretado e reescrito antes de assinar. Use o corpo em bruto.
  • Está a usar o segredo sem o prefixo whsec_, ou com um espaço ou quebra de linha no fim. O segredo é o texto todo.
  • Está a comparar com o valor errado: é t + "." + corpo, e a saída em hexadecimal minúsculo.
  • O segredo é de outro webhook (cada endereço tem o seu).
  • O relógio do seu servidor está desacertado em mais de 5 minutos.

Posso receber avisos em vários endereços?

Sim, até 10. Cada um tem o seu segredo, os seus eventos e o seu histórico de entregas.

Recebo o evento antes de a AGT validar o documento?

Sim, é para isso que existem eventos separados: document.created quando o documento é emitido (já tem número e é válido para o cliente) e document.agt_validated ou document.agt_rejected quando a AGT responde, mais tarde.