5. O CAD recebe o que aconteceu
Tudo que muda vira um no outbox do e é entregue ao callback_url do cadastro do . O que o próprio causou também volta (é eco, e ele descarta pela ). O desfecho de cada comando chega duas vezes: na resposta HTTP e como evento integracao:*, para o cliente que perdeu a resposta.
A cena
A cena 3D deste caso (roteiro receber-o-que-aconteceu) entra aqui quando a operação 3D publicar os roteiros dos casos de uso; até lá, o diagrama abaixo é a cena.
O envelope
{
"id": "0199bb3e-0000-7000-8000-00000000004a",
"ocorridoEm": "2026-10-31T12:04:11.000Z",
"origem": {
"autoria": { "id": "…", "tipo": "usuario" },
"plugin": "ocorrencia",
"solicitacao": "…",
"versao": "1.0.0"
},
"payload": {
"de": "em_deslocamento",
"id": "…",
"ocorrenciaId": "…",
"para": "no_local"
},
"tipo": "ocorrencia:despacho-mudou-status"
}autoria.tipo é usuario, integrador ou sistema; origem.solicitacao é o x-solicitacao-id do comando que causou o evento. O metadado de entrega vai em cabeçalho, nunca no corpo: o corpo é byte a byte o mesmo envelope que a fila por inscrição entrega a uma sessão.
const CABECALHO = {
AGREGADO: 'x-geohub-agregado',
ASSINATURA: 'x-geohub-assinatura',
EVENTO_ID: 'x-geohub-evento-id',
SEQUENCIA: 'x-geohub-sequencia',
TIMESTAMP: 'x-geohub-timestamp',
} as const;
/** O plugin do DESFECHO do comando — a resposta, não o eco. */
const PLUGIN_DE_INTEGRACAO = 'integracao';
/** ±5 min, dos dois lados: relógio adiantado é tão comum quanto atrasado. */
const TOLERANCIA_EM_SEGUNDOS = 300;const CABECALHO = {
AGREGADO: 'x-geohub-agregado',
ASSINATURA: 'x-geohub-assinatura',
EVENTO_ID: 'x-geohub-evento-id',
SEQUENCIA: 'x-geohub-sequencia',
TIMESTAMP: 'x-geohub-timestamp',
};
/** O plugin do DESFECHO do comando — a resposta, não o eco. */
const PLUGIN_DE_INTEGRACAO = 'integracao';
/** ±5 min, dos dois lados: relógio adiantado é tão comum quanto atrasado. */
const TOLERANCIA_EM_SEGUNDOS = 300;# O que o Geohub manda ao seu callback_url:
POST https://cad.exemplo.gov.br/geohub/eventos
Content-Type: application/json
X-Geohub-Evento-Id: 0199bb3e-0000-7000-8000-00000000004a
X-Geohub-Timestamp: 1761912251
X-Geohub-Assinatura: sha256=<hex do HMAC-SHA256(segredo, "1761912251." + corpo cru)>
X-Geohub-Agregado: ocorrencia:0199…
X-Geohub-Sequencia: 4
{ …o envelope… }
# responda 204As cinco obrigações do receptor
Cada uma tem um teste no exemplo do integrador.
Confira a assinatura sobre o corpo cru, antes de qualquer parse. X-Geohub-Assinatura é sha256=<hex> do HMAC-SHA256 de <X-Geohub-Timestamp>.<corpo cru> com o segredo do cadastro; o timestamp é epoch em segundos e a janela é de ±5 min.
/**
* HMAC-SHA256 de `<timestamp>.<corpo cru>`, comparado em tempo constante.
*
* O timestamp entra DENTRO do HMAC: sem isso, trocá-lo seria grátis e a
* janela de replay deixaria de existir.
*/
private assinaturaConfere(
cabecalhos: Record<string, string | undefined>,
corpo: string,
): boolean {
if (!this.janelaConfere(cabecalhos)) return false;
const recebida = /^sha256=(?<hex>[0-9a-f]{64})$/u.exec(
cabecalhos[CABECALHO.ASSINATURA] ?? '',
)?.groups?.['hex'];
if (recebida === undefined) return false;
const esperada = createHmac('sha256', this.opcoes.segredo)
.update(`${cabecalhos[CABECALHO.TIMESTAMP] as string}.${corpo}`, 'utf-8')
.digest('hex');
return timingSafeEqual(
Buffer.from(recebida, 'hex'),
Buffer.from(esperada, 'hex'),
);
}/**
* HMAC-SHA256 de `<timestamp>.<corpo cru>`, comparado em tempo constante.
*
* O timestamp entra DENTRO do HMAC: sem isso, trocá-lo seria grátis e a
* janela de replay deixaria de existir.
*/
assinaturaConfere(cabecalhos, corpo) {
if (!this.janelaConfere(cabecalhos)) return false;
const recebida = /^sha256=(?<hex>[0-9a-f]{64})$/u.exec(
cabecalhos[CABECALHO.ASSINATURA] ?? '',
)?.groups?.['hex'];
if (recebida === undefined) return false;
const esperada = createHmac('sha256', this.opcoes.segredo)
.update(`${cabecalhos[CABECALHO.TIMESTAMP]}.${corpo}`, 'utf-8')
.digest('hex');
return timingSafeEqual(
Buffer.from(recebida, 'hex'),
Buffer.from(esperada, 'hex'),
);
}# Conferir a assinatura no shell
printf '%s.%s' "$TS" "$CORPO" | openssl dgst -sha256 -hmac "$SEGREDO"Deduplique pelo id do envelope, descarte o próprio eco e responda 204 — também para o repetido e para o eco. O integracao:* não é eco: é a resposta ao comando, pelo segundo caminho.
/**
* O status que volta ao Geohub é a resposta do protocolo:
*
* - `401` — assinatura ou janela: o Geohub não insiste com quem recusa a
* credencial dele;
* - `400` — corpo que não é envelope: insistir não conserta;
* - `204` — aceito. **Também para o repetido e para o eco**: eles foram
* entregues com sucesso; o que se faz com eles é decisão de quem recebe,
* e responder erro faria o Geohub reenviar para sempre.
*/
private async receber(req: IncomingMessage): Promise<number> {
const corpo = await corpoCruDe(req);
const cabecalhos = req.headers as Record<string, string | undefined>;
// Sobre o corpo CRU e antes de qualquer parse: conferir depois provaria
// a assinatura de outra coisa.
if (!this.assinaturaConfere(cabecalhos, corpo)) {
this.descartes.push({
eventoId: cabecalhos[CABECALHO.EVENTO_ID],
motivo: this.janelaConfere(cabecalhos)
? 'assinatura-invalida'
: 'timestamp-fora-da-janela',
});
return 401;
}
const lido: unknown = JSON.parse(corpo);
if (!ehEnvelope(lido)) {
this.descartes.push({
eventoId: cabecalhos[CABECALHO.EVENTO_ID],
motivo: 'envelope-invalido',
});
return 400;
}
this.recebidos.push(lido);
if (this.vistos.has(lido.id)) {
this.descartes.push({ eventoId: lido.id, motivo: 'repetido' });
return 204;
}
this.vistos.add(lido.id);
/*
* O eco é sobre fato de DOMÍNIO que este CAD causou. O desfecho
* (`integracao:*`) também vem com a autoria dele — é assim que o Geohub
* sabe a quem entregá-lo —, mas ele é a RESPOSTA ao comando, não o eco
* dele: é por ele que o cliente que perdeu a resposta da chamada
* descobre o id que nasceu (task-550). Descartá-lo seria jogar fora o
* único retorno que o webhook traz.
*/
if (
lido.origem.plugin !== PLUGIN_DE_INTEGRACAO &&
lido.origem.autoria.tipo === 'integrador' &&
lido.origem.autoria.id === this.opcoes.integradorId
) {
this.descartes.push({ eventoId: lido.id, motivo: 'eco-proprio' });
return 204;
}
this.enfileirar(
cabecalhos[CABECALHO.AGREGADO] ?? lido.id,
Number(cabecalhos[CABECALHO.SEQUENCIA]),
lido,
);
return 204;
}/**
* O status que volta ao Geohub é a resposta do protocolo:
*
* - `401` — assinatura ou janela: o Geohub não insiste com quem recusa a
* credencial dele;
* - `400` — corpo que não é envelope: insistir não conserta;
* - `204` — aceito. **Também para o repetido e para o eco**: eles foram
* entregues com sucesso; o que se faz com eles é decisão de quem recebe,
* e responder erro faria o Geohub reenviar para sempre.
*/
async receber(req) {
const corpo = await corpoCruDe(req);
const cabecalhos = req.headers;
// Sobre o corpo CRU e antes de qualquer parse: conferir depois provaria
// a assinatura de outra coisa.
if (!this.assinaturaConfere(cabecalhos, corpo)) {
this.descartes.push({
eventoId: cabecalhos[CABECALHO.EVENTO_ID],
motivo: this.janelaConfere(cabecalhos)
? 'assinatura-invalida'
: 'timestamp-fora-da-janela',
});
return 401;
}
const lido = JSON.parse(corpo);
if (!ehEnvelope(lido)) {
this.descartes.push({
eventoId: cabecalhos[CABECALHO.EVENTO_ID],
motivo: 'envelope-invalido',
});
return 400;
}
this.recebidos.push(lido);
if (this.vistos.has(lido.id)) {
this.descartes.push({ eventoId: lido.id, motivo: 'repetido' });
return 204;
}
this.vistos.add(lido.id);
/*
* O eco é sobre fato de DOMÍNIO que este CAD causou. O desfecho
* (`integracao:*`) também vem com a autoria dele — é assim que o Geohub
* sabe a quem entregá-lo —, mas ele é a RESPOSTA ao comando, não o eco
* dele: é por ele que o cliente que perdeu a resposta da chamada
* descobre o id que nasceu (task-550). Descartá-lo seria jogar fora o
* único retorno que o webhook traz.
*/
if (
lido.origem.plugin !== PLUGIN_DE_INTEGRACAO &&
lido.origem.autoria.tipo === 'integrador' &&
lido.origem.autoria.id === this.opcoes.integradorId
) {
this.descartes.push({ eventoId: lido.id, motivo: 'eco-proprio' });
return 204;
}
this.enfileirar(
cabecalhos[CABECALHO.AGREGADO] ?? lido.id,
Number(cabecalhos[CABECALHO.SEQUENCIA]),
lido,
);
return 204;
}Ordene por X-Geohub-Sequencia dentro do agregado. A sequência é do seu cadastro, por agregado (ocorrencia:<id>, guarnicao:<id>, jornada:<id>): 1, 2, 3… sem buraco. Um buraco na chegada é um evento ainda em reenvio.
/**
* Aplica em ordem de `sequencia` **dentro do agregado**.
*
* A sequência é deste cadastro, por agregado: 1, 2, 3… sem buraco. O
* Geohub tenta entregar em ordem, mas um evento que falha não segura os
* seguintes — segurar transformaria um callback lento em fila parada.
* Então o buraco na chegada é um evento em reenvio, e o que chega
* adiantado espera por ele.
*
* Sequência ilegível (cabeçalho ausente ou adulterado) aplica na hora: o
* cabeçalho **não** entra no HMAC, e travar a projeção por causa de um
* metadado não provado seria entregar o controle a quem está no caminho.
*/
private enfileirar(
agregado: string,
sequencia: number,
envelope: EnvelopeDoGeohub,
): void {
if (!Number.isFinite(sequencia)) {
this.aplicar(envelope);
return;
}
const ultima = this.sequencias.get(agregado) ?? sequencia - 1;
if (sequencia > ultima + 1) {
const esperando = this.espera.get(agregado) ?? [];
esperando.push(envelope);
this.espera.set(agregado, esperando);
return;
}
this.sequencias.set(agregado, sequencia);
this.aplicar(envelope);
this.drenar(agregado, sequencia);
}/**
* Aplica em ordem de `sequencia` **dentro do agregado**.
*
* A sequência é deste cadastro, por agregado: 1, 2, 3… sem buraco. O
* Geohub tenta entregar em ordem, mas um evento que falha não segura os
* seguintes — segurar transformaria um callback lento em fila parada.
* Então o buraco na chegada é um evento em reenvio, e o que chega
* adiantado espera por ele.
*
* Sequência ilegível (cabeçalho ausente ou adulterado) aplica na hora: o
* cabeçalho **não** entra no HMAC, e travar a projeção por causa de um
* metadado não provado seria entregar o controle a quem está no caminho.
*/
enfileirar(agregado, sequencia, envelope) {
if (!Number.isFinite(sequencia)) {
this.aplicar(envelope);
return;
}
const ultima = this.sequencias.get(agregado) ?? sequencia - 1;
if (sequencia > ultima + 1) {
const esperando = this.espera.get(agregado) ?? [];
esperando.push(envelope);
this.espera.set(agregado, esperando);
return;
}
this.sequencias.set(agregado, sequencia);
this.aplicar(envelope);
this.drenar(agregado, sequencia);
}Guarde o estado por . Não existe viatura-chegou: chegada é a mudança de status de um despacho para no_local, e da dele.
/**
* O estado de UM despacho.
*
* Nunca o estado "da ocorrência no local": `no_local` é propriedade do
* despacho de uma viatura, e a ocorrência pode ter outra ainda em
* deslocamento.
*/
despacho(id: DespachoId): EstadoDoDespacho | undefined {
return this.despachos.get(id);
}/**
* O estado de UM despacho.
*
* Nunca o estado "da ocorrência no local": `no_local` é propriedade do
* despacho de uma viatura, e a ocorrência pode ter outra ainda em
* deslocamento.
*/
despacho(id) {
return this.despachos.get(id);
}/**
* A projeção: o que o CAD mostra na tela.
*
* Três tipos bastam para o atendimento, e os três dizem coisas
* diferentes:
*
* - `ocorrencia:despachada` — nasceu um despacho. É aqui que o
* `despachoId` aparece pela primeira vez;
* - `ocorrencia:despacho-mudou-status` — a chegada ao local é esta, com
* `para: 'no_local'`. Não existe um `viatura-chegou`;
* - `ocorrencia:mudou-status` — a OCORRÊNCIA mudou. `em_atendimento` é
* consequência da PRIMEIRA chegada, e não diz nada sobre os outros
* despachos.
*/
private aplicar(envelope: EnvelopeDoGeohub): void {
this.aplicados.push(envelope);
const payload = envelope.payload;
if (envelope.tipo === 'ocorrencia:despachada') {
const despacho = payload['despacho'] as
| {
id: string;
ocorrenciaId: string;
recurso?: { identificador?: string };
status: string;
}
| undefined;
if (despacho === undefined) return;
const id = despachoId(despacho.id);
this.despachos.set(id, {
despachoId: id,
ocorrenciaId: ocorrenciaId(despacho.ocorrenciaId),
...(despacho.recurso?.identificador === undefined
? {}
: { recurso: despacho.recurso.identificador }),
status: despacho.status,
});
return;
}
if (envelope.tipo === 'ocorrencia:despacho-mudou-status') {
const id = despachoId(String(payload['id']));
const anterior = this.despachos.get(id);
this.despachos.set(id, {
despachoId: id,
ocorrenciaId: ocorrenciaId(String(payload['ocorrenciaId'])),
...(anterior?.recurso === undefined
? {}
: { recurso: anterior.recurso }),
status: String(payload['para']),
});
return;
}
if (envelope.tipo === 'ocorrencia:mudou-status') {
this.ocorrencias.set(
ocorrenciaId(String(payload['id'])),
String(payload['para']),
);
}
}/**
* A projeção: o que o CAD mostra na tela.
*
* Três tipos bastam para o atendimento, e os três dizem coisas
* diferentes:
*
* - `ocorrencia:despachada` — nasceu um despacho. É aqui que o
* `despachoId` aparece pela primeira vez;
* - `ocorrencia:despacho-mudou-status` — a chegada ao local é esta, com
* `para: 'no_local'`. Não existe um `viatura-chegou`;
* - `ocorrencia:mudou-status` — a OCORRÊNCIA mudou. `em_atendimento` é
* consequência da PRIMEIRA chegada, e não diz nada sobre os outros
* despachos.
*/
aplicar(envelope) {
this.aplicados.push(envelope);
const payload = envelope.payload;
if (envelope.tipo === 'ocorrencia:despachada') {
const despacho = payload['despacho'];
if (despacho === undefined) return;
const id = despachoId(despacho.id);
this.despachos.set(id, {
despachoId: id,
ocorrenciaId: ocorrenciaId(despacho.ocorrenciaId),
...(despacho.recurso?.identificador === undefined
? {}
: { recurso: despacho.recurso.identificador }),
status: despacho.status,
});
return;
}
if (envelope.tipo === 'ocorrencia:despacho-mudou-status') {
const id = despachoId(String(payload['id']));
const anterior = this.despachos.get(id);
this.despachos.set(id, {
despachoId: id,
ocorrenciaId: ocorrenciaId(String(payload['ocorrenciaId'])),
...(anterior?.recurso === undefined
? {}
: { recurso: anterior.recurso }),
status: String(payload['para']),
});
return;
}
if (envelope.tipo === 'ocorrencia:mudou-status') {
this.ocorrencias.set(
ocorrenciaId(String(payload['id'])),
String(payload['para']),
);
}
}Quem recebe o quê, e as garantias
| eventos | destinatário |
|---|---|
ocorrencia:* | só o integrador originador da , se ativo |
jornada:* | todo integrador ativo com callback |
integracao:* | o integrador que mandou o comando (agregado solicitacao:<id>) |
- Entrega at-least-once, pelo outbox
integrador_evento, drenado por um cron a cada 15 s. Qualquer2xxserve;204sem corpo é o mais barato. - Timeout de 5 s;
3xxconta como falha (redirect não é seguido). - Falha reagenda com backoff exponencial de 1 min dobrando até 1 h, teto de 10 tentativas. O que esgota fica como dead letter (
falhou) e pode ser reenviado pelo , voltando o status parapendente, com a mesma sequência. - O
callback_urlprecisa ser HTTPS público, com o host na allowlist do cadastro; o validador recusa loopback, IP privado e host sem DNS.
A tabela completa dos eventos e dos payloads está na referência; o cadastro do integrador, na mesma página.