Guia 9/12
Webhooks
A clínica envia os eventos para o seu endereço HTTPS à medida que acontecem.
Eventos
campanha.enviada— Campanha enviadacirurgia.fechada— Cirurgia fechadacomunicado.publicado— Comunicado interno publicadoconsentimento.revogado— Consentimento retiradoconsulta.assinada— Consulta assinadademonstracao.pedida— Pedido de demonstração do AnagenSysdocumento.assinado— Documento assinadodocumento.emitido— Documento emitidodocumento_fiscal.emitido— Documento fiscal emitidoepisodio.aberto— Episódio de tratamento abertoepisodio.estado_alterado— Estado do episódio alteradoexecucao.areas_confirmadas— Áreas do dia 0 confirmadasexportacao.concluida— Exportação total prontaformulario.respondido— Formulário respondidoincidente.registado— Incidente de segurança registadolead.convertido— Lead convertido em pacientelead.criado— Lead criadolead.fase_alterada— Lead mudou de fasemarcacao.cancelada— Marcação canceladamarcacao.confirmada— Marcação confirmadamarcacao.criada— Marcação criadamarco.concluido— Marco do acompanhamento concluídomotor.versao_aprovada— Parâmetros do motor aprovadospaciente.criado— Paciente criadopaciente.fundido— Pacientes fundidospagamento.recebido— Pagamento recebidopedido_titular.recebido— Pedido de titular recebidoplano.aprovado— Plano cirúrgico aprovadoportal.consentimento_revogado— Autorização retirada no portalportal.documento_enviado— Documento enviado pelo pacienteportal.fotos_enviadas— Fotografias de acompanhamento enviadasportal.pedido_criado— Pedido criado no portalpre_avaliacao.recebida— Pré-avaliação online recebidaproposta.aceite— Proposta aceiteproposta.enviada— Proposta enviadaproposta.recusada— Proposta recusadareclamacao.recebida— Reclamação, elogio ou sugestão recebidatarefa.concluida— Tarefa concluídatarefa.criada— Tarefa criadawebhook.teste— Entrega de teste
O esquema do conteúdo de cada evento está na referência (secção Webhooks). O conteúdo nunca leva dados clínicos.
Entrega
POST /anagensys/eventos HTTP/1.1
Host: crm.exemplo.pt
Content-Type: application/json; charset=UTF-8
User-Agent: AnagenSys-Webhooks/1.0
X-Anagen-Evento: lead.criado
X-Anagen-Evento-Id: evt_6f1c2a9b0d3e4f5a6b7c
X-Anagen-Entrega: 1842
X-Anagen-Tentativa: 1
X-Anagen-Assinatura: t=1761646500,v1=5f2b0e…9c1a
{"evento":"lead.criado","id":"evt_6f1c2a9b0d3e4f5a6b7c","criado_em":"2026-10-28T10:15:00+00:00","clinica_id":1,"dados":{"id":412,"nome":"Rita Matos","origem":"site"}}- Responda com qualquer
2xxem menos de 10 segundos; faça o trabalho pesado depois (fila). - Falhas (não-2xx, tempo esgotado, rede) repetem-se ao fim de 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h, 24 h e depois desiste-se. A clínica vê cada entrega e pode reenviá-la.
- Depois de muitas falhas seguidas o destino é desativado e a clínica é avisada.
- As entregas podem chegar mais de uma vez ou fora de ordem: ignore repetições pelo
iddo evento e usecriado_em. - Só destinos
https://em redes públicas; não se seguem redireções.
Verificar a assinatura
X-Anagen-Assinatura: t=<epoch>,v1=<hex> em que hex = HMAC_SHA256(segredo, t + "." + corpo_cru). Recuse se a assinatura não coincidir (comparação em tempo constante) ou se t estiver a mais de 5 minutos.
<?php
// Verificação da assinatura (use o corpo CRU, antes de qualquer descodificação).
$segredo = getenv('ANAGEN_WEBHOOK_SECRET');
$corpo = file_get_contents('php://input');
$cab = $_SERVER['HTTP_X_ANAGEN_ASSINATURA'] ?? '';
parse_str(str_replace(',', '&', $cab), $partes);
$t = (int) ($partes['t'] ?? 0);
$v1 = (string) ($partes['v1'] ?? '');
$esperada = hash_hmac('sha256', $t . '.' . $corpo, $segredo);
if (abs(time() - $t) > 300 || ! hash_equals($esperada, $v1)) {
http_response_code(401);
exit;
}
$evento = json_decode($corpo, true);
// Guarde $evento['id'] para ignorar repetições.
http_response_code(204);import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/anagensys/eventos', express.raw({ type: 'application/json' }), (req, res) => {
const cab = req.get('X-Anagen-Assinatura') || '';
const partes = Object.fromEntries(cab.split(',').map((p) => p.split('=')));
const t = Number(partes.t || 0);
const esperada = crypto.createHmac('sha256', process.env.ANAGEN_WEBHOOK_SECRET)
.update(`${t}.${req.body.toString('utf8')}`).digest('hex');
const valida = partes.v1 && esperada.length === partes.v1.length &&
crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(partes.v1));
if (!valida || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(401);
const evento = JSON.parse(req.body.toString('utf8'));
// Guarde evento.id para ignorar repetições.
res.sendStatus(204);
});import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
SEGREDO = os.environ["ANAGEN_WEBHOOK_SECRET"].encode()
@app.post("/anagensys/eventos")
def eventos():
corpo = request.get_data() # corpo cru
partes = dict(p.split("=", 1) for p in request.headers.get("X-Anagen-Assinatura", "").split(",") if "=" in p)
t = int(partes.get("t", 0))
esperada = hmac.new(SEGREDO, f"{t}.".encode() + corpo, hashlib.sha256).hexdigest()
if abs(time.time() - t) > 300 or not hmac.compare_digest(esperada, partes.get("v1", "")):
abort(401)
evento = json.loads(corpo)
# Guarde evento["id"] para ignorar repetições.
return "", 204using System.Security.Cryptography;
using System.Text;
app.MapPost("/anagensys/eventos", async (HttpRequest pedido) =>
{
using var leitor = new StreamReader(pedido.Body, Encoding.UTF8);
var corpo = await leitor.ReadToEndAsync();
var partes = pedido.Headers["X-Anagen-Assinatura"].ToString()
.Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
var t = long.Parse(partes.GetValueOrDefault("t", "0"));
var segredo = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("ANAGEN_WEBHOOK_SECRET")!);
var esperada = Convert.ToHexString(HMACSHA256.HashData(segredo, Encoding.UTF8.GetBytes($"{t}.{corpo}"))).ToLowerInvariant();
var recebida = partes.GetValueOrDefault("v1", "");
var valida = CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(esperada), Encoding.UTF8.GetBytes(recebida));
if (!valida || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return Results.Unauthorized();
// Guarde o «id» do evento para ignorar repetições.
return Results.NoContent();
});O segredo (whsec_…) é mostrado uma única vez, quando a clínica cria o destino ou o roda. Guarde-o num cofre de segredos.