Webhooks#
Um webhook avisa o seu sistema na hora em que algo acontece no GuruAgenda: um agendamento novo, um cancelamento, um sinal pago, uma mensagem no WhatsApp. Em vez de consultar a API de tempos em tempos, você informa uma URL e escolhe os eventos; a cada evento, enviamos um POST para ela.
Há três formatos de entrega:
| Formato | Para quê |
|---|---|
generic (padrão) |
n8n, Make, Zapier ou o seu próprio sistema. Recebe o evento completo em JSON, assinado. |
slack, discord, google_chat, teams |
Uma mensagem pronta no canal do chat. Veja Chats. |
custom |
Um JSON (ou formulário) no formato que o destino espera, montado por um template. Veja Template livre. |
O dono da conta cria webhooks em Configurações → API e Webhooks, ou por uma chave com o escopo webhooks:manage (POST /v1/webhooks; veja a referência).
O evento#
Cada entrega leva um evento:
{
"id": "evt_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "appointment.rescheduled",
"api_version": "2026-10-01",
"created_at": "2026-10-13T18:22:05-03:00",
"organization_id": 12,
"livemode": true,
"data": {
"object": { "object": "appointment", "id": 90321, "start": "2026-10-21T10:00:00-03:00", "…": "…" },
"changes": { "start": { "from": "2026-10-20T14:30:00-03:00", "to": "2026-10-21T10:00:00-03:00" } },
"batch_id": null
},
"context": {
"actor": { "type": "api_key", "id": "ga_live_a1b2c3d4", "name": "n8n" },
"source": "api",
"request_id": "req_01JABCDEF0123456789XYZ"
}
}
data.objecté o objeto completo, igual ao que a API devolve (GET /v1/appointments/{id}, por exemplo). Você não precisa de uma segunda chamada para saber o nome do cliente ou o horário.data.changesdiz o que mudou, com o valor de antes e o de depois (nullquando não se aplica, como na criação).context.actordiz quem causou o evento:user(alguém da equipe),client(o cliente, pela página de agendamento),api_key,agent(o assistente de IA) ousystem(uma rotina automática).idé único por evento. Use-o para descartar repetições (veja Entrega e novas tentativas).api_versioné a versão do formato do evento, fixada na assinatura do webhook.
Os mesmos eventos ficam 30 dias em GET /v1/events, para quem prefere consultar.
Eventos#
Ao assinar, você pode usar curingas por grupo: appointment.*, queue.*, deposit.* e assim por diante.
| Evento | Quando |
|---|---|
appointment.created |
Agendamento criado, por qualquer canal. |
appointment.updated |
Mudou algo sem evento próprio: observações, serviços, preço, metadata. |
appointment.rescheduled |
Mudou a data, a hora ou o profissional. changes traz o valor anterior. |
appointment.confirmed |
Agendamento confirmado. |
appointment.cancelled |
Cancelado pelo cliente, pela equipe ou pelo sistema. |
appointment.completed |
Atendimento concluído. |
appointment.no_show |
O cliente faltou. |
appointment.deleted |
Agendamento excluído. data.object traz só id e deleted: true. |
appointment.reminder_sent |
Lembrete enviado ao cliente (e-mail ou WhatsApp). |
client.created |
Cliente novo na carteira. |
client.updated |
Dados do cliente ou metadata mudaram. |
client.first_appointment |
Primeiro agendamento do cliente na organização. |
queue.entry.created |
Cliente entrou na Fila Online. |
queue.entry.called |
Cliente chamado. |
queue.entry.in_service |
Atendimento em andamento. |
queue.entry.completed |
Atendimento da fila concluído. |
queue.entry.skipped |
Cliente pulado. |
queue.entry.no_show |
Chamado e não apareceu no prazo. |
queue.entry.cancelled |
Entrada cancelada. |
deposit.created |
Sinal cobrado (PIX gerado). |
deposit.paid |
Sinal pago. |
deposit.expired |
Sinal não pago no prazo. |
deposit.refunded |
Sinal estornado. |
service.created |
Serviço criado. |
service.updated |
Serviço alterado. |
service.deactivated |
Serviço desativado ou excluído. |
professional.created |
Profissional entrou na equipe. |
professional.deactivated |
Profissional saiu da equipe. |
blocked_time.created |
Bloqueio de agenda criado. |
blocked_time.deleted |
Bloqueio de agenda removido. |
whatsapp.message.received |
Mensagem recebida no WhatsApp próprio da organização. Traz o cliente, quando o telefone é de alguém da carteira. |
whatsapp.message.sent |
Mensagem automática enviada pelo GuruAgenda (confirmação, lembrete…). |
whatsapp.message.failed |
Mensagem automática que falhou. |
whatsapp.connection.updated |
O WhatsApp próprio conectou ou desconectou. |
Além desses, o botão Testar (ou POST /v1/webhooks/{id}/test) envia na hora um evento ping e mostra a resposta do seu endpoint. O ping só sai pelo teste: não dá para assiná-lo.
A lista cresce com o tempo, sem aviso de quebra: ignore tipos de evento que você não conhece.
Filtros#
Para receber só parte dos eventos, a assinatura aceita filters. Valores na mesma lista são alternativas (OU); listas diferentes precisam valer juntas (E). Lista vazia não filtra.
| Filtro | Exemplo de uso |
|---|---|
professional_ids |
Só os agendamentos de uma profissional. |
service_ids |
Só um serviço. |
statuses |
Só agendamentos e entradas da fila que ficaram num status (depois do evento). |
types |
Só TELEMEDICINE ou só IN_PERSON. |
sources |
Em appointment.created, só o que veio de um canal: public_booking (página de agendamento), dashboard (equipe), recurrence, api ou agent. |
Um filtro que não se aplica ao evento (por exemplo, service_ids num whatsapp.message.received) não bloqueia a entrega.
Headers de cada entrega#
POST /seu/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: GuruAgenda-Webhooks/1.0 (+https://api.guruagenda.com.br/docs/webhooks)
X-GuruAgenda-Event: appointment.created
X-GuruAgenda-Event-Id: evt_01JAB2C3D4E5F6G7H8J9K0MNPQ
X-GuruAgenda-Delivery-Id: dlv_01JAB3C4D5E6F7G8H9J0K1MNPQ
X-GuruAgenda-Webhook-Id: whk_01JAB1C2D3E4F5G6H7J8K9MNPQ
X-GuruAgenda-Timestamp: 1792183859
X-GuruAgenda-Signature: t=1792183859,v1=44f18d12cf…
webhook-id: evt_01JAB2C3D4E5F6G7H8J9K0MNPQ
webhook-timestamp: 1792183859
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Também enviamos os headers que você cadastrar na assinatura (headers, até 10). É assim que se usa o Header Auth do nó Webhook do n8n: cadastre, por exemplo, X-Webhook-Token com o mesmo valor da credencial.
Verificar a assinatura#
Toda entrega generic e custom é assinada com o segredo do webhook (whsec_…). Ele aparece uma vez, na criação e na rotação; você também pode informar o seu próprio segredo (16 a 128 caracteres, sem espaços) ao criar o webhook. Verificar é opcional, mas é o que garante que o pedido veio do GuruAgenda e não foi alterado.
Há dois esquemas no mesmo pedido. Use um deles.
X-GuruAgenda-Signature: HMAC-SHA256, em hexadecimal, de "{t}.{corpo}", usando o segredo inteiro (com o whsec_) como chave. t é o horário da assinatura (segundos desde 1970).
- Leia o corpo cru, byte a byte, antes de qualquer parse de JSON. Corpo reformatado não bate.
- Separe
tev1do header. - Recuse se
testiver a mais de 5 minutos do horário atual (proteção contra repetição). - Calcule o HMAC e compare com
v1em tempo constante. - Depois de girar o segredo, por 24 horas o header também traz
v0, assinado com o segredo anterior. Aceitev1ouv0para trocar o segredo sem perder entregas.
webhook-signature: segue a especificação Standard Webhooks, com bibliotecas prontas em várias linguagens (pip install standardwebhooks, npm install standardwebhooks). Funciona com o segredo gerado pelo GuruAgenda; se você informou o seu próprio segredo e ele não for base64, use o X-GuruAgenda-Signature.
Python#
import hashlib
import hmac
import time
def verify_guruagenda(secret, signature_header, raw_body, tolerance=300):
"""True se o corpo (bytes) veio do GuruAgenda com este segredo."""
parts = dict(item.split("=", 1) for item in signature_header.split(",") if "=" in item)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, parts.get(key, "")) for key in ("v1", "v0"))
Com Flask, o corpo cru é request.get_data():
@app.post("/hooks/guruagenda")
def guruagenda_hook():
if not verify_guruagenda(SECRET, request.headers.get("X-GuruAgenda-Signature", ""), request.get_data()):
return "", 400
event = request.get_json()
# … trate o evento; responda rápido
return "", 200
Node.js#
const crypto = require("crypto");
function verifyGuruAgenda(secret, signatureHeader, rawBody, toleranceSeconds = 300) {
const parts = {};
for (const item of signatureHeader.split(",")) {
const i = item.indexOf("=");
if (i > 0) parts[item.slice(0, i)] = item.slice(i + 1);
}
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
return ["v1", "v0"].some((key) =>
typeof parts[key] === "string" && parts[key].length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts[key]), Buffer.from(expected)));
}
Com Express, peça o corpo cru com express.raw:
app.post("/hooks/guruagenda", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyGuruAgenda(process.env.GURUAGENDA_WEBHOOK_SECRET, req.get("X-GuruAgenda-Signature") || "", req.body)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// … trate o evento; responda rápido
res.sendStatus(200);
});
n8n#
Veja o nó Code pronto em n8n, Make e Zapier.
Entrega e novas tentativas#
- Sucesso é qualquer resposta
2xxem até 10 segundos (5 para conectar). O corpo da resposta é ignorado. Responda logo e processe depois, se o trabalho for demorado. - Falha é tempo esgotado, erro de rede, redirecionamento (
3xx, que não seguimos),4xxou5xx. - Novas tentativas depois de 1 min, 5 min, 30 min, 2 h, 6 h, 12 h e 24 h (com uma pequena variação aleatória): 8 tentativas em cerca de 45 horas. Em
429, respeitamos oRetry-After(até 1 hora). 410 Gonedesativa o webhook na hora. Use-o para dizer "não mande mais".- Desativação automática: 7 dias seguidos só com falhas desativam o webhook, e o dono da conta recebe um e-mail. Reativar é um clique (ou
PATCH /v1/webhooks/{id}comactive: true). - Ordem não é garantida, e um mesmo evento pode chegar mais de uma vez (numa nova tentativa ou num reenvio manual). Descarte o
idrepetido e, para saber o estado mais recente, comparecreated_atou confie nodata.object, que sempre traz o objeto inteiro.
Cada tentativa fica registrada por 30 dias, com o código de resposta, o tempo e o começo da resposta: em Configurações → API e Webhooks ou em GET /v1/webhooks/{id}/deliveries. Uma entrega pode ser reenviada por POST /v1/webhooks/{id}/deliveries/{delivery_id}/redeliver.
Segurança do destino#
- A URL precisa ser
https, num domínio público. Recusamos IPs internos, endereços privados e redirecionamentos. - Nunca enviamos anotações clínicas, fichas ou prontuários por webhook.
- Para chats, enviamos os dados do cliente reduzidos por padrão (veja Chats).