Webhooks

Um webhook é o caminho inverso da integração: em vez de você consultar a Nilo, a Nilo chama você quando alguma coisa muda. Você registra uma URL e o tipo de recurso a observar, e passa a receber uma requisição a cada alteração.

No FHIR isso é o recurso Subscription.

Só o canal rest-hook é suportado, ou seja, uma chamada HTTP para uma URL sua. Os demais canais do FHIR — e-mail, mensagem, websocket — recusam a chamada.

Criar uma assinatura

POST
/fhir/resources/Subscription
1curl -X POST https://landing-zone-api.nilo.services/fhir/resources/Subscription \
2 -H "x-api-key: <apiKey>" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "resourceType": "Subscription",
6 "status": "requested",
7 "criteria": "Patient",
8 "reason": "Sincronizar cadastro de pacientes",
9 "channel": {
10 "type": "rest-hook",
11 "endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir"
12 }
13}'
Response
1{
2 "resourceType": "Subscription",
3 "status": "requested",
4 "criteria": "Patient",
5 "reason": "Sincronizar cadastro de pacientes",
6 "channel": {
7 "type": "rest-hook",
8 "endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir",
9 "header": [
10 "secret: 8f14e45fceea167a5a36dedd4bea2543"
11 ]
12 },
13 "id": "db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
14 "meta": {
15 "versionId": "MTc4NjAyMTQ1MjkwNTAwMDM2Nw",
16 "lastUpdated": "2026-08-11T12:20:03.771000Z",
17 "tag": [
18 {
19 "code": "412",
20 "system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
21 }
22 ]
23 }
24}
CampoObrigatórioO que significa
resourceTypesimConstante Subscription
statussimNa criação, tem de ser requested
reasonsimExigido pelo FHIR. O texto é seu, e a plataforma não o usa para nada
criteriasimO tipo de recurso a observar — opcionalmente com filtros, veja abaixo
channel.typesimrest-hook
channel.endpointsimA URL que a Nilo vai chamar
channel.header[]nãoCabeçalhos extras, no formato Nome: valor
contact[]nãoE-mails avisados quando a assinatura é desativada. Só system: email

Uma assinatura viva por criteria. Não dá para ter duas assinaturas ativas ou pendentes para o mesmo critério — e a comparação é da string inteira, filtros incluídos. Desligue a primeira antes de criar outra.

criteria aceita filtros

Além do tipo de recurso, o criteria aceita parâmetros de busca depois de ?, e só notifica o que casar com eles:

Patient
Encounter?status=finished
Appointment?_include=Appointment:patient

_include e _revinclude mudam o formato da notificação: em vez do recurso nu, você recebe um Bundle do tipo searchset com o recurso e os relacionados. Veja O que chega no seu endpoint.

Este recurso não tem identifier — o FHIR R4 não define o campo para ele. O identificador da assinatura vem em meta.tag[0].code, e é por ele que a Nilo a reencontra. Guarde-o se precisar correlacionar.

O aperto de mão

Uma assinatura não nasce ativa. Depois de responder ao seu POST, a Nilo chama a sua URL com uma requisição de verificação:

  1. você cria a assinatura e recebe status: requested;
  2. a Nilo faz um POST na sua URL com um Bundle do tipo history, contendo um SubscriptionStatus de type: handshake;
  3. se a sua URL responder com sucesso (2xx), a assinatura passa a active;
  4. se responder com erro, a assinatura passa a error, e o motivo fica no campo error do recurso.

Enquanto a assinatura não estiver active, nenhuma notificação é entregue.

O aperto de mão pode chegar antes da resposta do POST. Ele é disparado durante o processamento da criação, não depois dela — então o seu endpoint precisa estar de pé antes de você chamar a API, e não pode depender de nada que só exista depois da resposta.

E não espere ver status: active na resposta da criação: ela devolve requested.

O que chega no seu endpoint

A notificação é um POST no seu endpoint, com o recurso que mudou no corpo:

SituaçãoCorpo da notificação
criteria simplesO recurso, nu
criteria com _include ou _revincludeUm Bundle do tipo searchset, com o recurso e os relacionados
Recurso excluídoO recurso, com um meta.tag de code: DELETE

A exclusão é notificada com o recurso que deixou de existir, marcado por meta.tag[].code igual a DELETE. Trate esse caso separadamente: o corpo parece uma atualização normal.

E nela os filtros do criteria quase não são avaliados: como o recurso já não existe, só active, status e o modificador :missing são aplicados. Um filtro mais complexo não é considerado, e a notificação de exclusão chega mesmo assim.

Eventos de Bundle só geram notificação quando o Bundle é do tipo document. Uma carga em lote não dispara webhook — o que dispara são as escritas que ela faz.

Como conferir que a chamada veio da Nilo

Toda entrega leva uma assinatura HMAC do corpo da requisição:

CabeçalhoConteúdo
Content-Typeapplication/fhir+json
X-Hub-Signaturesha256=<hmac> — HMAC-SHA256 do corpo, com o segredo da assinatura

Confira a assinatura em toda entrega. Calcule o HMAC-SHA256 do corpo cru da requisição com o seu segredo e compare com o valor depois de sha256=. Uma chamada sem assinatura válida não veio da Nilo.

O segredo

Você define o segredo mandando um cabeçalho chamado secret no channel.header[]:

POST
/fhir/resources/Subscription
1curl -X POST https://landing-zone-api.nilo.services/fhir/resources/Subscription \
2 -H "x-api-key: <apiKey>" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "resourceType": "Subscription",
6 "status": "requested",
7 "criteria": "Encounter",
8 "reason": "Sincronizar atendimentos",
9 "channel": {
10 "type": "rest-hook",
11 "endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir",
12 "header": [
13 "secret: 8f14e45fceea167a5a36dedd4bea2543",
14 "X-Origem: nilo"
15 ]
16 },
17 "contact": [
18 {
19 "system": "email",
20 "value": "integracao@acmesaude.com.br"
21 }
22 ]
23}'

Mande sempre o seu. Não recebendo um cabeçalho secret, a Nilo gera um internamente — e esse valor gerado não volta na resposta nem nas leituras. Você fica com um webhook cuja assinatura não consegue conferir.

O segredo que você mandar, esse, fica visível nas leituras seguintes da assinatura: trate a leitura de Subscription como dado sensível.

Os demais cabeçalhos que você mandar em channel.header[] são repassados em toda entrega, junto com os dois acima. É por aí que se manda um token estático, se o seu endpoint exigir um — inclusive o próprio secret, que também vai repassado.

Prefira validar pelo X-Hub-Signature, não pelo cabeçalho secret: o primeiro prova que o corpo não foi alterado; o segundo é só um valor fixo.

Um cabeçalho customizado com o nome Content-Type ou X-Hub-Signature sobrescreve o da Nilo, em silêncio — e aí a sua própria verificação de assinatura quebra. Não use esses dois nomes.

Se o seu ambiente estiver configurado para isso, a Nilo obtém um token OAuth2 e o envia em Authorization, substituindo o que você tiver posto nesse cabeçalho. A configuração vale também para o aperto de mão, então o seu endpoint de token precisa estar de pé antes de você criar a assinatura. Fale com o Suporte.

O que a Nilo espera do seu endpoint

Responda rápido. A entrega tem tempo limite: 5 segundos para conectar e 15 para a resposta. Um endpoint lento é tratado como falha.

Responda 2xx assim que receber, e processe depois. Não faça trabalho pesado dentro da requisição.

Entrega, retentativa e desativação

A Nilo espera 2xx. O que acontece quando não recebe:

Resposta do seu endpointO que a Nilo faz
2xxEntregue
Falha de rede, timeout, 5xx ou 429Reprocessa automaticamente, por algumas tentativas
4xx, exceto 429Falha permanente daquele evento — ele não é reenviado

A partir da terceira tentativa sem sucesso, a assinatura é desativada. Ela passa a status: error, o motivo fica no campo error do recurso, e um alerta é enviado aos e-mails de contact — com o criteria, o endpoint, o status HTTP, a mensagem de erro e o link para o recurso que não pôde ser entregue.

Reativação automática

Uma assinatura em error não precisa ser recriada. No próximo evento daquele tipo de recurso, a Nilo reenvia o aperto de mão sozinha; respondendo 2xx, a assinatura volta a active e o evento é entregue normalmente.

O que ela não faz é reenviar os eventos ocorridos enquanto estava em error. Para não perder dados, reconcilie o período com um GET do recurso correspondente, filtrando por _lastUpdated.

Desligar uma assinatura

POST
/fhir/resources/Subscription
1curl -X POST https://landing-zone-api.nilo.services/fhir/resources/Subscription \
2 -H "x-api-key: <apiKey>" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "resourceType": "Subscription",
6 "status": "off",
7 "criteria": "Patient",
8 "reason": "Encerrando a integração de pacientes",
9 "channel": {
10 "type": "rest-hook",
11 "endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir"
12 }
13}'

Mande status: off com o mesmo criteria. A assinatura ativa ou pendente daquele recurso é encerrada, e o end do recurso passa a trazer o instante do desligamento.

Não havendo assinatura ativa nem pendente para aquele criteria, a chamada é recusada com Not found webhook active or requested for criteria=….

Consultar assinaturas

GET
/fhir/resources/Subscription
1curl -G https://landing-zone-api.nilo.services/fhir/resources/Subscription \
2 -H "x-api-key: <apiKey>" \
3 -d _count=50 \
4 --data-urlencode _page_token=Cjj3YopYuf%2F%2F%2F%2F%2BABd%2BbgE0m...dSANQAFoLCUSM7w9VYUqaEANglLWUugQ%3D \
5 --data-urlencode _tag=https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook|412 \
6 -d criteria=Patient \
7 --data-urlencode url=https://webhooks.acmesaude.com.br/nilo/fhir
Response
1{
2 "resourceType": "Bundle",
3 "type": "searchset",
4 "entry": [
5 {
6 "fullUrl": "https://landing-zone-api.nilo.services/fhir/resources/Subscription/db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
7 "resource": {
8 "resourceType": "Subscription",
9 "status": "active",
10 "criteria": "Patient",
11 "reason": "-",
12 "channel": {
13 "type": "rest-hook",
14 "endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir",
15 "header": [
16 "secret: 8f14e45fceea167a5a36dedd4bea2543"
17 ]
18 },
19 "id": "db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
20 "meta": {
21 "versionId": "MTc4NjAyMTQ1MjkwNTAwMDM2Nw",
22 "lastUpdated": "2026-08-11T12:20:03.771000Z",
23 "tag": [
24 {
25 "code": "412",
26 "system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
27 }
28 ]
29 }
30 },
31 "search": {
32 "mode": "match"
33 }
34 }
35 ],
36 "link": []
37}

Não filtre por status. Por uma particularidade do armazenamento, o status gravado é sempre off, e o valor real é reposto só na hora de responder — então status=active devolve vazio e status=off devolve tudo. Liste sem filtro, ou use o _tag.

Para reencontrar uma assinatura específica, use o _tag que veio em meta.tag[0]:

$curl --request GET \
> --url 'https://landing-zone-api.nilo.services/fhir/resources/Subscription?_tag=https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook|412' \
> --header 'x-api-key: SUA_API_KEY'
statusO que significa
requestedCriada, aguardando o aperto de mão. Ainda não recebe notificações
activeAperto de mão respondido — entregando
errorDesativada por falhas de entrega, ou por aperto de mão que falhou. O motivo está em error, e ela é reativada sozinha quando o endpoint voltar
offDesligada manualmente. Só volta criando uma assinatura nova
GET
/fhir/resources/Subscription/:id
1curl https://landing-zone-api.nilo.services/fhir/resources/Subscription/id \
2 -H "x-api-key: <apiKey>"

Diferente da busca, esta leitura não devolve um Bundle — mas também não devolve o recurso nu. A resposta é um envelope com fullUrl, search e resource.

Response
1{
2 "fullUrl": "https://landing-zone-api.nilo.services/fhir/resources/Subscription/db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
3 "resource": {
4 "resourceType": "Subscription",
5 "status": "active",
6 "criteria": "Patient",
7 "reason": "-",
8 "channel": {
9 "type": "rest-hook",
10 "endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir",
11 "header": [
12 "secret: 8f14e45fceea167a5a36dedd4bea2543"
13 ]
14 },
15 "id": "db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
16 "meta": {
17 "versionId": "MTc4NjAyMTQ1MjkwNTAwMDM2Nw",
18 "lastUpdated": "2026-08-11T12:20:03.771000Z",
19 "tag": [
20 {
21 "code": "412",
22 "system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
23 }
24 ]
25 }
26 },
27 "search": {
28 "mode": "match"
29 }
30}

Erros

Recusa é 400. Neste recurso o issue[].code é sempre exception, sem expression, e o details.text vem embrulhado — algo como ValidationError: ['Only channel=rest-hook subscriptions are supported'].

Não compare o texto por igualdade: procure a mensagem como substring, ou simplesmente trate 400 como “assinatura recusada”.

Mensagem, dentro do textoQuando
Only channel=rest-hook subscriptions are supportedchannel.type diferente de rest-hook
Subscriptions must start with status=requestedCriação com status diferente de requested
criteria=… is not a valid FHIR resourceO criteria não é um tipo de recurso do FHIR
Only contact system=email are supported. …Um contact com system diferente de email
The contact email "…" is invalid.O e-mail do contato não é válido
Not found webhook active or requested for criteria=…status: off sem assinatura correspondente
mensagem de constraint da plataformaJá existe assinatura viva para aquele criteria
Response
1{
2 "issue": [
3 {
4 "code": "exception",
5 "details": {
6 "text": "ValidationError: ['Only channel=rest-hook subscriptions are supported']"
7 },
8 "severity": "error"
9 }
10 ],
11 "resourceType": "OperationOutcome"
12}

Trocar o endpoint ou o critério

Não há atualização: desligue a assinatura e crie outra. Para não ficar sem cobertura entre as duas chamadas, faça as duas numa carga em lote, com a entrada de status: off primeiro — as entradas são processadas na ordem do array.

O que a integração não cobre

  • canais que não sejam rest-hook;
  • alterar uma assinatura: desligue e crie outra;
  • reenvio dos eventos ocorridos enquanto a assinatura esteve em error ou off;
  • um identifier próprio: a assinatura é identificada por meta.tag;
  • filtrar assinaturas por status na busca.