Skip to content

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 ​

json
{
  "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.

ts
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;
js
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;
sh
# 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 204

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

ts
/**
 * 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'),
  );
}
js
/**
 * 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'),
  );
}
sh
# 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.

ts
/**
 * 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;
}
js
/**
 * 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.

ts
/**
 * 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);
}
js
/**
 * 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.

ts
/**
 * 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);
}
js
/**
 * 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);
}
ts
/**
 * 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']),
    );
  }
}
js
/**
 * 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 ​

eventosdestinatá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. Qualquer 2xx serve; 204 sem corpo é o mais barato.
  • Timeout de 5 s; 3xx conta 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 para pendente, com a mesma sequência.
  • O callback_url precisa 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.