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
- 1Crie um endereço que receba
POSTUm endereço HTTPS público do seu sistema, por exemplo
https://loja.exemplo.ao/webhooks/mucanda. Tem de responder em menos de 10 segundos. - 2Registe-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. - 3Verifique 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, responda400e ignore o aviso. - 4Responda depressa com
2xxGrave o aviso, responda
200e processe-o depois. Qualquer resposta que não seja2xx(ou nenhuma, em 10 segundos) conta como falha e o aviso é repetido. - 5Use «Testar»
No ecrã dos webhooks, Testar envia já um aviso
webhook.testassinado: 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çalho | Para que serve |
|---|---|
Mucanda-Signature | A assinatura: t=<hora>,v1=<assinatura>. Ver abaixo. |
Mucanda-Event | O tipo do evento (document.created…). |
Mucanda-Event-Id | O identificador do evento (evt_…). É o que usa para ignorar repetidos. |
Mucanda-Delivery | O identificador desta entrega ao seu endereço. |
Mucanda-Attempt | O número da tentativa: 1 na primeira, 2 na primeira repetição… |
User-Agent | Mucanda-Webhooks/1.0. |
{
"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
| Evento | Quando acontece | O que traz em data |
|---|---|---|
document.created | Um documento foi emitido (factura, factura/recibo, nota de crédito, guia, pró-forma…). | documentoId, numero, tipo, tipoDescricao, dataEmissao, clienteId, lojaId, total, documentoOrigemId |
document.agt_validated | A 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_rejected | A AGT recusou o documento. Os erros vão no evento, tal como a AGT os devolveu. | documentoId, numero, tipo, validadoEm, erros |
document.cancelled | Uma nota de crédito anulou por inteiro o documento de origem. | documentoId, numero, notaDeCreditoId, notaDeCreditoNumero, dataAnulacao |
payment.received | Foi registado um pagamento e emitido o recibo. | reciboId, numero, valor, clienteId, lojaId, data |
customer.created | Foi 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.
Mucanda-Signature: t=1760606102,v1=ff2db01184b35eba032ba91f5204c2ff8855c9b9cd95929205357721e96744c5
| Parte | Sentido |
|---|---|
t | A hora do envio, em segundos desde 1970 (hora Unix). |
v1 | A assinatura: o HMAC-SHA256 em hexadecimal minúsculo. |
| O texto assinado | t + . + o corpo exacto recebido: por exemplo 1760606102.{"id":"evt_…"}. |
| A chave | O segredo do webhook inteiro, com o prefixo (whsec_…), como texto UTF-8. |
Verificar, passo a passo
- Leia o cabeçalho
Mucanda-Signaturee separete as assinaturasv1(pode haver mais do que uma: ver «Mudar o segredo»). - Recuse o aviso se
tdiferir da hora do seu servidor em mais de 5 minutos: protege contra alguém reenviar um aviso antigo que apanhou. - Calcule o HMAC-SHA256 de
t + "." + corpo, com o segredo, em hexadecimal minúsculo. - Compare com cada
v1em 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: 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
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)
})
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
$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);
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:
| Tentativa | Quando |
|---|---|
| 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âmetro | Sentido |
|---|---|
tipo | Só os eventos deste tipo (document.created…). |
desde | Só os eventos criados a partir deste instante (ISO 8601, UTC). |
tamanho e cursor | Paginação, como nas outras listagens (ver Paginação). |
{
"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 exemplongrok). - 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
| Assunto | Limite |
|---|---|
| Webhooks por empresa | Até 10 endereços. |
| Tempo para responder | 10 segundos. |
| Tentativas | 1 + 8 repetições, durante cerca de 3 dias. |
| Tolerância de relógio | 5 minutos na verificação da hora. |
| Eventos guardados | 30 dias, em GET /api/eventos. |
| Histórico das entregas | Visí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.