Skip to navigation

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
curl -X POST https://landing-zone-api.nilo.services/fhir/resources/Subscription \
-H "x-api-key: <apiKey>" \
-H "Content-Type: application/json" \
-d '{
"resourceType": "Subscription",
"status": "requested",
"criteria": "Patient",
"reason": "Sincronizar cadastro de pacientes",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir"
}
}'
Response
{
"resourceType": "Subscription",
"status": "requested",
"criteria": "Patient",
"reason": "Sincronizar cadastro de pacientes",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir",
"header": [
"secret: 8f14e45fceea167a5a36dedd4bea2543"
]
},
"id": "db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"meta": {
"versionId": "MTc4NjAyMTQ1MjkwNTAwMDM2Nw",
"lastUpdated": "2026-08-11T12:20:03.771000Z",
"tag": [
{
"code": "412",
"system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
}
]
}
}
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.typesimSó rest-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
curl -X POST https://landing-zone-api.nilo.services/fhir/resources/Subscription \
-H "x-api-key: <apiKey>" \
-H "Content-Type: application/json" \
-d '{
"resourceType": "Subscription",
"status": "requested",
"criteria": "Encounter",
"reason": "Sincronizar atendimentos",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir",
"header": [
"secret: 8f14e45fceea167a5a36dedd4bea2543",
"X-Origem: nilo"
]
},
"contact": [
{
"system": "email",
"value": "integracao@acmesaude.com.br"
}
]
}'

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
curl -X POST https://landing-zone-api.nilo.services/fhir/resources/Subscription \
-H "x-api-key: <apiKey>" \
-H "Content-Type: application/json" \
-d '{
"resourceType": "Subscription",
"status": "off",
"criteria": "Patient",
"reason": "Encerrando a integração de pacientes",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir"
}
}'

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
curl -G https://landing-zone-api.nilo.services/fhir/resources/Subscription \
-H "x-api-key: <apiKey>" \
-d status=active
Response
{
"resourceType": "Bundle",
"type": "searchset",
"entry": [
{
"fullUrl": "https://landing-zone-api.nilo.services/fhir/resources/Subscription/db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"resource": {
"resourceType": "Subscription",
"status": "active",
"criteria": "Patient",
"reason": "-",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir",
"header": [
"secret: 8f14e45fceea167a5a36dedd4bea2543"
]
},
"id": "db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"meta": {
"versionId": "MTc4NjAyMTQ1MjkwNTAwMDM2Nw",
"lastUpdated": "2026-08-11T12:20:03.771000Z",
"tag": [
{
"code": "412",
"system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
}
]
}
},
"search": {
"mode": "match"
}
}
],
"link": []
}

Filtrar a busca

ParâmetroFiltra porModificadores
criteriaO critério observado. Sem modificador, casa o que começa com o valor, ignorando maiúsculas e acentos:contains casa o valor em qualquer posição; :exact casa só o valor idêntico, maiúsculas e acentos incluídos
statusO estado da assinatura: requested, active, error ou offnenhum
urlA URL de destino, comparada por igualdade:below devolve as URLs que começam pelo valor; :above devolve as URLs que são prefixo do valor
_tagA etiqueta que veio em meta.tag[0], e é o caminho para uma assinatura específicanenhum

Para ver o que está entregando de fato:

GET
/fhir/resources/Subscription
curl -G https://landing-zone-api.nilo.services/fhir/resources/Subscription \
-H "x-api-key: <apiKey>" \
-d status=active

Mais de um estado numa busca só — separados por vírgula, ou repetindo o parâmetro, tanto faz:

?status=active,error
?status=active&status=error

As duas formas devolvem as assinaturas que estejam em qualquer um dos estados pedidos. É uma exceção à regra do FHIR, em que repetir o parâmetro significa E: como a assinatura tem um estado só, exigir dois ao mesmo tempo não devolveria nada.

Um status vazio não filtra nada, e a busca volta completa.

status não aceita modificador. O estado real não vive no armazenamento — a Nilo o resolve antes de buscar. Com modificador — status:not, status:missing — o filtro escapa dessa tradução e é repassado direto, e o armazenamento tem toda assinatura gravada como off: a resposta não terá relação com o estado real. Liste os estados que você quer, sem modificador.

Buscar por um trecho do critério

O criteria é gravado como você o mandou, filtros incluídos, e sem modificador a busca casa só o que começa com o valor. Para achar um trecho no meio da string, use :contains:

GET
/fhir/resources/Subscription
curl -G https://landing-zone-api.nilo.services/fhir/resources/Subscription \
-H "x-api-key: <apiKey>" \
-d criteria:contains=Patient
Response
{
"resourceType": "Bundle",
"type": "searchset",
"entry": [
{
"fullUrl": "https://landing-zone-api.nilo.services/fhir/resources/Subscription/db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"resource": {
"resourceType": "Subscription",
"status": "active",
"criteria": "Patient",
"reason": "-",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir"
},
"id": "db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"meta": {
"tag": [
{
"code": "412",
"system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
}
]
}
},
"search": {
"mode": "match"
}
},
{
"fullUrl": "https://landing-zone-api.nilo.services/fhir/resources/Subscription/7c0a1b94-30f2-4c1b-9a1e-6f2d8b5c4e11",
"resource": {
"resourceType": "Subscription",
"status": "off",
"criteria": "Encounter?_include=Encounter:patient",
"reason": "-",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir/encounter"
},
"id": "7c0a1b94-30f2-4c1b-9a1e-6f2d8b5c4e11",
"meta": {
"tag": [
{
"code": "418",
"system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
}
]
}
},
"search": {
"mode": "match"
}
}
],
"link": []
}

Encounter?_include=Encounter:patient também volta: :contains casa em qualquer posição, e ignora maiúsculas e acentos. Com criteria=Patient, viria só a primeira.

Combinar filtros

Os parâmetros se somam com E — a assinatura tem de casar com todos:

GET
/fhir/resources/Subscription
curl -G https://landing-zone-api.nilo.services/fhir/resources/Subscription \
-H "x-api-key: <apiKey>" \
-d criteria:contains=Patient \
-d status=active
Response
{
"resourceType": "Bundle",
"type": "searchset",
"entry": [
{
"fullUrl": "https://landing-zone-api.nilo.services/fhir/resources/Subscription/db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"resource": {
"resourceType": "Subscription",
"status": "active",
"criteria": "Patient",
"reason": "-",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir"
},
"id": "db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"meta": {
"tag": [
{
"code": "412",
"system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
}
]
}
},
"search": {
"mode": "match"
}
}
],
"link": []
}

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
curl https://landing-zone-api.nilo.services/fhir/resources/Subscription/id \
-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
{
"fullUrl": "https://landing-zone-api.nilo.services/fhir/resources/Subscription/db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"resource": {
"resourceType": "Subscription",
"status": "active",
"criteria": "Patient",
"reason": "-",
"channel": {
"type": "rest-hook",
"endpoint": "https://webhooks.acmesaude.com.br/nilo/fhir",
"header": [
"secret: 8f14e45fceea167a5a36dedd4bea2543"
]
},
"id": "db1704e5-8c93-4a26-b0f7-45e28a1c9b60",
"meta": {
"versionId": "MTc4NjAyMTQ1MjkwNTAwMDM2Nw",
"lastUpdated": "2026-08-11T12:20:03.771000Z",
"tag": [
{
"code": "412",
"system": "https://landing-zone-api.nilo.services/fhir/resources/NamingSystem/landing-zone-api--fhir-webhook"
}
]
}
},
"search": {
"mode": "match"
}
}

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
{
"issue": [
{
"code": "exception",
"details": {
"text": "ValidationError: ['Only channel=rest-hook subscriptions are supported']"
},
"severity": "error"
}
],
"resourceType": "OperationOutcome"
}

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.