# GuruAgenda API — documentação completa > Texto gerado de https://api.guruagenda.com.br/docs/ para colar numa IA ou num assistente de código. Traz os guias (regras gerais, webhooks, chats, template livre, n8n/Make/Zapier, CRMs, erros) e a referência de todas as rotas e schemas da v1. --- ## GuruAgenda API A API do GuruAgenda dá acesso aos dados da sua organização: equipe, serviços, clientes, horários livres, agendamentos, Fila Online, sinais e eventos. Use-a para integrar a agenda com CRM (HubSpot, Pipedrive, RD Station…), automações (n8n, Make, Zapier) e sistemas próprios. Cada rota, campo e exemplo está na [referência interativa](https://api.guruagenda.com.br/docs/reference), gerada do próprio código. Esta página explica as regras que valem para todas elas. Para receber avisos na hora em que algo acontece, veja [Webhooks](https://api.guruagenda.com.br/docs/webhooks); para montar integrações sem código, [n8n, Make e Zapier](https://api.guruagenda.com.br/docs/n8n). Vai usar uma IA ou um assistente de código para montar a integração? O botão *Copiar para IA*, no topo desta página, copia a documentação inteira (guias e todas as rotas) num texto só. O mesmo texto fica em [`/docs/llms-full.txt`](https://api.guruagenda.com.br/docs/llms-full.txt). ### Começando 1. Peça ao dono da conta para criar uma chave em *Configurações → API e Webhooks*, escolhendo os escopos (veja abaixo). A chave aparece **uma vez só**: guarde-a num cofre de senhas ou na credencial da sua ferramenta. 2. Teste a chave: ```bash curl https://api.guruagenda.com.br/v1/me \ -H "Authorization: Bearer ga_live_SUA_CHAVE" ``` A resposta traz a organização, o plano, as funcionalidades ligadas e o nome e os escopos da chave. É a mesma chamada que o botão *Test credential* do n8n faz. A API faz parte dos planos superiores. Sem ela no plano, toda chamada responde `403` com o código [`feature_not_enabled`](https://api.guruagenda.com.br/docs/errors#feature_not_enabled). ### Autenticação e escopos Toda requisição leva a chave no header `Authorization: Bearer ga_live_…`. Não aceitamos a chave na URL nem em cookie. A chave é da organização, não de uma pessoa, e o dono pode revogá-la a qualquer momento na mesma tela. | Escopo | O que libera | |---|---| | `read` | Leitura: equipe, serviços, clientes, horários, agendamentos, fila, sinais e eventos. | | `write` | Criar e alterar clientes, agendamentos e entradas da fila. | | `webhooks:manage` | Criar, editar, testar e remover webhooks. | Rota fora dos escopos da chave responde `403` com [`forbidden_scope`](https://api.guruagenda.com.br/docs/errors#forbidden_scope). Recurso de outra organização responde sempre `404`, nunca `403`. ### Formato dos dados - **JSON em UTF-8** nos dois sentidos (`Content-Type: application/json`). - **Datas e horas** em ISO 8601 com o fuso: `2026-10-20T14:30:00-03:00`. Ao enviar, inclua o fuso (`-03:00` ou `Z`). Datas sem hora: `2026-10-20`. - **Dinheiro** em texto com duas casas, mais a moeda: `"price": "250.00", "currency": "BRL"`. Nunca número de ponto flutuante. - **Duração** em minutos inteiros (`duration_minutes`). - **Telefone** sai em E.164 (`+5511999999999`). Na entrada, aceitamos o formato que vier (`(11) 99999-9999`) e normalizamos. - **CPF e CNPJ** só com dígitos. - **Campos nunca somem**: sem valor, o campo vem com `null`. Isso facilita mapear campos no n8n e no Make. - **Referências** vêm como `*_id` e um mini-objeto com o nome (`"client_id": 4821, "client": {"id": 4821, "name": "Maria Souza", …}`). Algumas rotas aceitam `?expand=` para trazer o objeto completo; a referência diz quais. ### Paginação Listas vêm em páginas de até 200 itens (padrão 50, parâmetro `limit`): ```json { "data": [ … ], "has_more": true, "next_cursor": "WzkwMzIxXQ", "next": "https://api.guruagenda.com.br/v1/appointments?limit=50&cursor=WzkwMzIxXQ" } ``` `next` é a URL completa da próxima página (`null` na última). No nó *HTTP Request* do n8n, use a paginação *Response Contains Next URL* com `{{ $response.body.next }}`. Quem monta a URL à mão usa `cursor=`. O cursor é opaco: não tente decodificá-lo. Filtros com vários valores aceitam as duas formas, `status[]=CONFIRMED&status[]=PENDING` e `status=CONFIRMED,PENDING`. Para sincronizar só o que mudou, as listas de clientes e agendamentos aceitam `updated_since` (data e hora ISO 8601). Para receber mudanças na hora, sem consultar de tempos em tempos, use [webhooks](https://api.guruagenda.com.br/docs/webhooks). ### Repetir um POST com segurança Ferramentas de automação repetem uma etapa quando a rede falha. Para que a repetição não crie um segundo agendamento, mande o header `Idempotency-Key` com um valor único por operação (até 255 caracteres; um UUID serve): ```bash curl -X POST https://api.guruagenda.com.br/v1/appointments \ -H "Authorization: Bearer ga_live_SUA_CHAVE" \ -H "Idempotency-Key: 6f1c2b9e-1d3a-4f57-9a0e-5b2d8c7e4a10" \ -H "Content-Type: application/json" \ -d '{"service_ids": [3], "start": "2026-10-20T14:30:00-03:00", "client_id": 4821}' ``` Mesma chave e mesmo corpo nas 24 horas seguintes devolvem a resposta original, com o header `Idempotent-Replayed: true`. Mesma chave com corpo diferente responde `409` [`duplicate_request`](https://api.guruagenda.com.br/docs/errors#duplicate_request). A referência marca as rotas que aceitam o header. ### Limites de uso | Limite | Valor | |---|---| | Por chave | 120 requisições por minuto, até 10 por segundo | | Por organização (todas as chaves) | 300 requisições por minuto | | Horários livres (`/availability/*`) | 30 requisições por minuto por chave | Toda resposta traz `X-RateLimit-Limit`, `X-RateLimit-Remaining` e `X-RateLimit-Reset`. Acima do limite, a resposta é `429` [`rate_limited`](https://api.guruagenda.com.br/docs/errors#rate_limited) com `Retry-After` em segundos: espere esse tempo antes de tentar de novo. ### Erros Erros respondem em `application/problem+json`, com um `code` estável em inglês. Trate o erro pelo `code`; o texto em `detail` é para pessoas e pode mudar. Todos os códigos estão em [Erros](https://api.guruagenda.com.br/docs/errors). Toda resposta, de sucesso ou de erro, traz o header `X-Request-Id`. Ao falar com o suporte, informe esse valor. ### Versões A versão vai na URL (`/v1/`). Dentro da v1 só fazemos mudanças que não quebram integrações: campo novo, evento novo, valor novo numa lista de opções. Por isso, ignore campos que você não conhece e não trave em valores desconhecidos. Remover ou renomear um campo só numa `v2`. --- ## 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](https://api.guruagenda.com.br/docs/chats). | | `custom` | Um JSON (ou formulário) no formato que o destino espera, montado por um template. Veja [Template livre](https://api.guruagenda.com.br/docs/template). | 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](https://api.guruagenda.com.br/docs/reference)). ### O evento Cada entrega leva **um** evento: ```json { "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.changes`** diz o que mudou, com o valor de antes e o de depois (`null` quando não se aplica, como na criação). - **`context.actor`** diz quem causou o evento: `user` (alguém da equipe), `client` (o cliente, pela página de agendamento), `api_key`, `agent` (o assistente de IA) ou `system` (uma rotina automática). - **`id`** é único por evento. Use-o para descartar repetições (veja [Entrega e novas tentativas](https://api.guruagenda.com.br/docs/webhooks#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 ```http 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). 1. Leia o corpo **cru**, byte a byte, antes de qualquer parse de JSON. Corpo reformatado não bate. 2. Separe `t` e `v1` do header. 3. Recuse se `t` estiver a mais de 5 minutos do horário atual (proteção contra repetição). 4. Calcule o HMAC e compare com `v1` em tempo constante. 5. Depois de girar o segredo, por 24 horas o header também traz `v0`, assinado com o segredo anterior. Aceite `v1` ou `v0` para trocar o segredo sem perder entregas. **`webhook-signature`**: segue a especificação [Standard Webhooks](https://www.standardwebhooks.com/), 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 ```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()`: ```python @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 ```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`: ```js 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](https://api.guruagenda.com.br/docs/n8n#verificar-a-assinatura-no-n8n). ### Entrega e novas tentativas - **Sucesso** é qualquer resposta `2xx` em 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), `4xx` ou `5xx`. - **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 o `Retry-After` (até 1 hora). - **`410 Gone`** desativa 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}` com `active: true`). - **Ordem não é garantida**, e um mesmo evento pode chegar mais de uma vez (numa nova tentativa ou num reenvio manual). Descarte o `id` repetido e, para saber o estado mais recente, compare `created_at` ou confie no `data.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](https://api.guruagenda.com.br/docs/chats)). --- ## Chats: Slack, Discord, Google Chat e Teams Para avisar a equipe num canal de chat, não é preciso n8n nem código. Crie um webhook de entrada no chat, cole a URL no GuruAgenda e escolha os eventos: nós montamos a mensagem no formato de cada plataforma. ### Como obter a URL | Plataforma | Onde criar | `format` | |---|---|---| | Slack | Crie um app em *api.slack.com/apps* → *Incoming Webhooks* → *Add New Webhook to Workspace* → escolha o canal. A URL começa com `https://hooks.slack.com/`. | `slack` | | Discord | No canal: *Editar canal → Integrações → Webhooks → Novo webhook → Copiar URL*. A URL começa com `https://discord.com/api/webhooks/`. | `discord` | | Google Chat | No espaço: *Apps e integrações → Webhooks → Adicionar webhook*. Só funciona em contas Google Workspace. A URL começa com `https://chat.googleapis.com/v1/spaces/`. | `google_chat` | | Microsoft Teams | No canal: *⋯ → Workflows → "Send webhook alerts to a channel"* (ou "Post to a channel when a webhook request is received"), salve e copie a URL. Os antigos *conectores do Office 365* foram descontinuados pela Microsoft. | `teams` | Depois, no GuruAgenda: *Configurações → API e Webhooks → Novo webhook*, escolha a plataforma, cole a URL, marque os eventos e clique em *Testar*. Pela API, é o mesmo `POST /v1/webhooks` com o `format` da tabela. A URL de um webhook de chat funciona como uma senha: quem a tem consegue postar no canal. Por isso a guardamos cifrada e, depois de salva, a mostramos cortada (`https://hooks.slack.com/services/T0…a1b2`). Conferimos se a URL é da plataforma escolhida (no Teams, aceitamos qualquer URL `https`, porque o Workflows usa vários domínios). ### A mensagem Cada evento vira uma mensagem com título, os dados principais e um botão para abrir o registro no GuruAgenda: ```text 📅 Novo agendamento Cliente: Maria Souza · (11) 9****-9999 Profissional: Ana Lima Serviço: Consulta (60 min) · R$ 250,00 Quando: ter, 20/10/2026 às 14:30 Status: Confirmado Origem: link público [Abrir no GuruAgenda] ``` No Slack ela sai em Block Kit, no Discord como *embed* colorido pelo tipo de evento, no Google Chat como cartão e no Teams como Adaptive Card. O texto simples vai junto em todos, para notificações e leitores de tela. Reagendamentos trazem também o horário anterior; cancelamentos, quem cancelou e o motivo. **Agrupamento por agendamento:** - **Google Chat:** os eventos do mesmo agendamento (criado, reagendado, sinal pago, cancelado) ficam na mesma conversa (thread). - **Discord:** em reagendamento e cancelamento, editamos a mensagem original em vez de postar outra; se ela foi apagada, postamos uma nova. - **Slack e Teams:** uma mensagem por evento (os webhooks dessas plataformas não permitem editar). ### Dados do cliente Canais de chat costumam ter muita gente. Por padrão, mostramos o cliente reduzido: primeiro nome e último sobrenome, telefone com o meio oculto (`(11) 9****-9999`) e sem e-mail. Para mostrar o nome completo, o telefone e o e-mail, ligue `show_full_personal_data` no webhook (na tela, *Mostrar dados completos do cliente*). Anotações clínicas nunca vão para o chat. ### Trocar o texto do título O campo `message_template` (até 2.000 caracteres) troca o título da mensagem por um texto seu, mantendo o cartão com os dados. Ele aceita as mesmas variáveis e filtros do [template livre](https://api.guruagenda.com.br/docs/template): ```jinja {{ data.object.professional.name }}: novo horário em {{ data.object.start | date('d/m H:i') }} ``` ### Entrega - Mensagens de chat não são assinadas (a plataforma não verifica assinatura). - Uma mensagem atrasada horas não serve para a recepção: tentamos de novo só depois de 1 e de 5 minutos e, se ainda falhar, registramos a falha. - Respeitamos o limite de cada plataforma: no máximo uma mensagem por segundo por webhook, e o `Retry-After` quando a plataforma pede para esperar. - Se a plataforma responder que o webhook não existe mais (`404` ou `410`, por exemplo quando o canal foi apagado), desativamos o webhook e avisamos o dono da conta por e-mail. --- ## Template livre Alguns destinos esperam um formato próprio: o webhook de entrada de um CRM que quer `{"nome": …, "telefone": …}`, um sistema legado, um formulário. Com `format: "custom"`, você escreve o corpo do pedido como um template, e nós o preenchemos com os dados de cada evento. ### Exemplo ```json { "name": "CRM: novos agendamentos", "url": "https://crm.example.com/api/leads", "events": ["appointment.created"], "format": "custom", "template": { "method": "POST", "content_type": "application/json", "body": "{\"nome\": {{ data.object.client.name | json }}, \"telefone\": {{ data.object.client.phone | json }}, \"quando\": {{ data.object.start | date('d/m/Y H:i') | json }}, \"evento\": {{ type | json }}}", "headers": { "Authorization": "Bearer {{ secret_ref('crm_token') }}" }, "query": { "origem": "guruagenda" } }, "secrets": { "crm_token": "o-token-do-seu-crm" } } ``` O corpo, com um agendamento da Maria Souza, chega assim: ```json {"nome": "Maria Souza", "telefone": "+5511999999999", "quando": "20/10/2026 14:30", "evento": "appointment.created"} ``` ### Linguagem Os templates usam a sintaxe do [Jinja](https://jinja.palletsprojects.com/en/stable/templates/): `{{ … }}` insere um valor, `{% if … %}` e `{% for … %}` controlam o texto. **Variáveis disponíveis:** | Variável | O que é | |---|---| | `id`, `type`, `created_at`, `api_version` | Dados do evento. | | `data.object` | O objeto completo (agendamento, cliente, sinal…), como na API. | | `data.changes` | O que mudou, com `from` e `to`. | | `context.actor`, `context.source` | Quem causou o evento e por onde. | | `organization.name`, `organization.timezone` | A sua organização. | | `now` | Data e hora da entrega. | | `secret_ref('nome')` | Um segredo guardado no webhook (veja abaixo). | **Filtros:** `json`, `date`, `default`, `upper`, `lower`, `truncate`, `replace`, `join`, `first`, `last` e `length`. O `date` formata datas com `d` (dia), `m` (mês), `Y` (ano com 4 dígitos), `y` (2 dígitos), `H` (hora), `i` (minuto) e `s` (segundo), no fuso da organização: `{{ data.object.start | date('d/m/Y H:i') }}`. ### Corpo em JSON: sempre `| json` Com `content_type: "application/json"`, **toda** inserção `{{ … }}` no corpo precisa terminar no filtro `json`, e o valor vai **sem aspas em volta**: o filtro já gera o texto entre aspas, escapa caracteres especiais e escreve `null` quando o valor não existe. ```jinja "nome": {{ data.object.client.name | json }} ``` Assim, um nome com aspas ou quebra de linha nunca quebra o JSON. Ao salvar, montamos o corpo com um evento de exemplo e recusamos o template se o resultado não for JSON válido. ### Outros formatos | `content_type` | `body` | |---|---| | `application/json` (padrão) | Texto do template; o resultado precisa ser JSON. | | `application/x-www-form-urlencoded` | Objeto campo → template, por exemplo `{"nome": "{{ data.object.client.name }}"}`. | | `text/plain` | Texto livre. | `method` pode ser `POST` (padrão), `PUT` ou `PATCH`. `headers` e `query` aceitam até 10 itens cada, também como template. ### Segredos Tokens do destino não devem ficar no texto do template, porque o template aparece de volta na API e na tela. Guarde-os em `secrets` (até 10, nomes com letras minúsculas, números e `_`) e use `{{ secret_ref('nome') }}`. Os valores ficam cifrados e nunca voltam: a API mostra só `secret_names`, e o histórico de entregas mostra `••••` no lugar deles. No `PATCH`, os segredos enviados se juntam aos existentes; mande `null` para apagar um. ### Limites e erros - Template com até 16 KB; resultado com até 64 KB, montado em até 100 ms. - Não são permitidos `macro`, `call`, `include`, `import`, `extends`, nem mais de 2 laços um dentro do outro. - Template inválido ao salvar responde `422` [`template_invalid`](https://api.guruagenda.com.br/docs/errors#template_invalid), com o campo em `errors[]`. - Se um template falhar na entrega (um valor que estourou um limite, por exemplo), a entrega falha na hora, sem novas tentativas, e a falha aparece no histórico. As entregas do formato `custom` são assinadas como as do formato genérico: veja [Verificar a assinatura](https://api.guruagenda.com.br/docs/webhooks#verificar-a-assinatura). --- ## n8n, Make e Zapier Ferramentas de automação conversam com o GuruAgenda pelos nós genéricos de HTTP e de webhook. Esta página mostra a configuração no n8n; a mesma ideia vale para Make e Zapier. ### Chamar a API no n8n 1. Crie uma credencial *Bearer Auth* com a chave `ga_live_…` (ou *Header Auth* com o nome `Authorization` e o valor `Bearer ga_live_…`). 2. No nó *HTTP Request*, use a URL `https://api.guruagenda.com.br/v1/…` e essa credencial. 3. Para testar a credencial, chame `GET /v1/me`. **Listas com várias páginas:** no *HTTP Request*, em *Options → Pagination*, escolha o modo *Response Contains Next URL*, com *Next URL* `{{ $response.body.next }}`; em *Complete When*, escolha *Other* com a expressão `{{ !$response.body.has_more }}`. O `next` já é a URL completa da próxima página. **Repetição segura:** em nós que criam algo (`POST`), mande o header `Idempotency-Key` com um valor fixo por item, por exemplo `{{ $json.id }}-guruagenda`. Se o n8n repetir a etapa, nada é criado duas vezes. **Limites:** acima de 120 requisições por minuto a API responde `429`. Em fluxos com muitos itens, use *Batching* no *HTTP Request* (por exemplo, 2 itens por segundo). **Importar a API:** a especificação OpenAPI fica em `https://api.guruagenda.com.br/v1/openapi.json`, para Postman, Insomnia e outras ferramentas que importam OpenAPI. ### Receber eventos no n8n 1. Adicione o nó *Webhook*: método `POST`, *Respond* `Immediately`. 2. Copie a *Production URL* do nó. 3. No GuruAgenda, crie um webhook do tipo *Genérico* com essa URL e escolha os eventos. 4. Clique em *Testar* no GuruAgenda: chega um evento `ping`. Para ver um evento de verdade, crie um agendamento de teste. Enquanto monta o fluxo, você pode usar a *Test URL* (`/webhook-test/…`) do n8n, que só recebe com o fluxo em modo de escuta. Depois troque pela *Production URL* e ative o fluxo. O evento chega no campo `body` do item: `{{ $json.body.data.object.client.name }}` é o nome do cliente; `{{ $json.body.type }}` é o tipo do evento. Um nó *Switch* sobre `{{ $json.body.type }}` separa os caminhos (`appointment.created`, `appointment.cancelled`…). **Proteger a URL:** o jeito mais simples é o *Header Auth* do nó Webhook. Cadastre no webhook do GuruAgenda um header, por exemplo `X-Webhook-Token: um-valor-longo-e-aleatorio`, e crie no n8n uma credencial *Header Auth* com o mesmo nome e valor. Pedidos sem o header são recusados pelo n8n. Para garantir também que o conteúdo não foi alterado, verifique a assinatura. ### Verificar a assinatura no n8n 1. No nó *Webhook*, abra *Options* e ligue *Raw Body*. O corpo chega como arquivo binário `data`, byte a byte. 2. Logo depois, adicione um nó *Code* (*Run Once for All Items*, JavaScript) com o código abaixo, trocando o segredo. 3. O nó devolve o evento já lido do JSON; se a assinatura não bater, o fluxo para com erro. ```js const crypto = require('crypto'); const secret = 'whsec_…'; // o segredo do webhook no GuruAgenda const header = $input.first().json.headers['x-guruagenda-signature'] || ''; const rawBody = await this.helpers.getBinaryDataBuffer(0, 'data'); const parts = {}; for (const item of header.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) > 300) { throw new Error('Assinatura vencida ou ausente'); } const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'); const valid = ['v1', 'v0'].some((key) => parts[key] === expected); if (!valid) { throw new Error('Assinatura inválida'); } return [{ json: JSON.parse(rawBody.toString('utf8')) }]; ``` No n8n instalado no seu servidor, o módulo `crypto` precisa estar liberado: defina a variável de ambiente `NODE_FUNCTION_ALLOW_BUILTIN=crypto`. No n8n Cloud ele já vem liberado. Em vez de escrever o segredo no código, você pode guardá-lo numa variável de ambiente e ler com `$env.GURUAGENDA_WEBHOOK_SECRET`. ### Receitas **Agendamento novo vira tarefa no CRM** *Webhook* (eventos `appointment.created`) → nó do CRM (HubSpot, Pipedrive, RD Station…) criando a atividade com `{{ $json.body.data.object.start }}` e o cliente `{{ $json.body.data.object.client.email }}`. Para atualizar a mesma atividade depois, grave o id dela de volta no agendamento: `PATCH /v1/appointments/{{ $json.body.data.object.id }}` com `{"metadata": {"crm_activity_id": "…"}}`. Nos eventos seguintes (`appointment.rescheduled`, `appointment.cancelled`), o id volta em `data.object.metadata.crm_activity_id`. **Lead do formulário vira cliente** Gatilho do formulário (ou do CRM) → *HTTP Request* `POST /v1/clients` com `first_name`, `last_name`, `email`, `phone` e `metadata` com o id do lead. Se a pessoa já existir na carteira (mesmo e-mail, telefone ou CPF), a resposta é `200` com o cadastro existente, em vez de `201`; com `"on_match": "update"`, os dados enviados atualizam o cadastro. **Agendar a partir de outro sistema** `GET /v1/availability/slots?service_ids=3&date=2026-10-20` traz os horários livres (um item por profissional livre); `POST /v1/appointments` com o horário escolhido, o `client_id` (ou o `client` inline) e um `Idempotency-Key`. **Sincronizar de tempos em tempos, sem webhook** *Schedule Trigger* a cada 15 minutos → `GET /v1/appointments?updated_since={{ $now.minus({minutes: 20}).toISO() }}` com paginação. A sobreposição de 5 minutos evita perder alterações entre as execuções. ### Make - **Chamar a API:** módulo *HTTP → Make a request*, com o header `Authorization: Bearer ga_live_…`. Para listas, repita a chamada enquanto `has_more` for verdadeiro, usando o `next`. - **Receber eventos:** módulo *Webhooks → Custom webhook*. Copie a URL gerada para um webhook *Genérico* no GuruAgenda e clique em *Testar* para o Make aprender o formato (o `ping` traz poucos campos; para mapear um agendamento, crie um de teste com o cenário escutando). - Filtros com várias opções aceitam `status=CONFIRMED,PENDING`. ### Zapier - **Receber eventos:** gatilho *Webhooks by Zapier → Catch Hook*. Cole a URL num webhook *Genérico* do GuruAgenda. - **Chamar a API:** ação *Webhooks by Zapier → Custom Request*, com o header `Authorization: Bearer ga_live_…` e `Content-Type: application/json`. - **Gatilho por consulta:** *Webhooks by Zapier → Retrieve Poll* sobre `GET /v1/appointments?updated_since=…`. --- ## CRMs O GuruAgenda se liga a CRMs como HubSpot, Pipedrive, RD Station CRM, PipeRun e Ploomes por meio de uma ferramenta de automação (n8n, Make ou Zapier), que fica no meio traduzindo os dados. Esta página mostra os fluxos mais comuns e como guardar o vínculo entre os registros dos dois lados. O passo a passo das ferramentas está em [n8n, Make e Zapier](https://api.guruagenda.com.br/docs/n8n). ### Os fluxos mais comuns | Fluxo | Direção | Como | |---|---|---| | Lead ou contato do CRM vira cliente | CRM → GuruAgenda | Gatilho do CRM → `POST /v1/clients`, que encontra o cliente existente em vez de duplicar. | | Agendamento vira atividade ou reunião | GuruAgenda → CRM | Webhook `appointment.created` → criar a atividade no CRM. | | Reagendamento e cancelamento atualizam a atividade | GuruAgenda → CRM | `appointment.rescheduled` e `appointment.cancelled` → atualizar a atividade pelo id guardado em `metadata`. | | Atendimento concluído ou falta mudam o negócio | GuruAgenda → CRM | `appointment.completed` e `appointment.no_show` → registrar o resultado ou mover a etapa. | | Sinal pago marca o negócio como ganho | GuruAgenda → CRM | `deposit.paid` → atualizar o negócio. | | Equipe comercial agenda pelo CRM | CRM → GuruAgenda | `GET /v1/availability/slots` → `POST /v1/appointments`. | | Mensagem no WhatsApp vira nota no CRM | GuruAgenda → CRM | `whatsapp.message.received` → criar nota no contato. | O evento já traz o objeto completo (nome, telefone e e-mail do cliente, profissional, serviços, horários com fuso), então o nó do CRM cria a atividade sem consultar a API de novo. ### Guardar o vínculo entre os dois lados Clientes e agendamentos têm um campo `metadata` para você guardar ids de outros sistemas: até 50 chaves por registro, texto → texto. 1. **O id do CRM no GuruAgenda:** ao criar ou atualizar, mande `"metadata": {"hubspot_contact_id": "501"}`. Ele volta em todo `GET` e em todo webhook, e dá para buscar por ele: `GET /v1/clients?metadata[hubspot_contact_id]=501`. 2. **O id do GuruAgenda no CRM:** crie no CRM um campo personalizado (por exemplo, `guruagenda_id`) e grave nele o `id` do cliente ou do agendamento. Com isso, um `appointment.rescheduled` chega com `data.object.metadata.hubspot_meeting_id`, e o fluxo atualiza a reunião certa, sem procurar por nome ou data. ### Status do agendamento no CRM | GuruAgenda | Sugestão no CRM | |---|---| | `PENDING`, `CONFIRMED` | Agendada | | `COMPLETED` | Realizada | | `NO_SHOW` | Não compareceu | | `CANCELLED_CLIENT`, `CANCELLED_PROFESSIONAL` | Cancelada | | evento `appointment.rescheduled` | Reagendada (o horário anterior vem em `data.changes.start.from`) | ### Cuidados - **Datas:** os horários vêm em ISO 8601 com o fuso (`2026-10-20T14:30:00-03:00`). Alguns CRMs pedem UTC, data e hora separadas ou milissegundos desde 1970; converta na ferramenta de automação (no n8n, `{{ DateTime.fromISO($json.body.data.object.start).toUTC().toISO() }}`). - **Repetições:** um webhook pode chegar mais de uma vez. Antes de criar a atividade, confira se o `id` do evento já foi tratado, ou procure a atividade pelo id guardado em `metadata`. - **Ordem:** eventos podem chegar fora de ordem. Use `created_at` do evento para não sobrescrever um estado novo com um antigo. - **Limites do CRM:** cada CRM tem o seu limite de requisições. Em importações grandes, use os recursos de lote e espera da ferramenta de automação. --- ## Erros Todo erro da API responde em `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)): ```json { "type": "https://api.guruagenda.com.br/docs/errors#slot_unavailable", "title": "Horário indisponível", "status": 409, "code": "slot_unavailable", "detail": "Nenhum profissional livre neste horário.", "errors": [], "request_id": "req_01JABCDEF0123456789XYZ" } ``` - **`code`** é estável e em inglês. É por ele que a sua integração decide o que fazer. - **`title`** e **`detail`** são texto para pessoas, em português, e podem mudar. - **`errors`** lista os campos com problema (`field`, `code`, `message`) nos erros de validação. - **`request_id`** identifica a requisição. Informe-o ao suporte. - **`type`** aponta para o código nesta página. Erros `4xx` não melhoram com uma nova tentativa igual: corrija o pedido. Só `429`, `500` e `503` valem ser repetidos, esperando um pouco entre as tentativas. ### Autenticação e acesso #### `unauthorized` **401.** A chave não veio, está errada, foi revogada ou expirou. Confira o header `Authorization: Bearer ga_live_…`. Muitas tentativas com chave inválida bloqueiam o IP por 30 minutos. #### `forbidden_scope` **403.** A chave não tem o escopo que a rota exige (`read`, `write` ou `webhooks:manage`). Peça ao dono da conta uma chave com o escopo. #### `feature_not_enabled` **403.** O plano da organização não inclui a API, ou a rota depende de uma funcionalidade desligada (por exemplo, a Fila Online). #### `forbidden_overbook` **403.** O agendamento pediu encaixe (`allow_overbook: true`) num horário ocupado, e a organização não permite encaixe. ### Pedido inválido #### `validation_error` **400.** Um parâmetro ou campo do corpo é inválido: tipo errado, valor fora da lista, data mal formada, URL de webhook recusada. `errors[]` diz qual campo e por quê. #### `client_identifier_required` **422.** A organização exige telefone, e-mail ou CPF para cadastrar clientes, e o pedido não trouxe o que falta. `errors[]` diz quais campos. #### `template_invalid` **422.** O template de um webhook não compila ou usa algo que não é permitido. `errors[]` aponta o campo do template. ### Não encontrado #### `not_found` **404.** O recurso não existe nesta organização. Recurso de outra organização também responde `404`. ### Conflitos #### `slot_unavailable` **409.** O horário não está livre: outro agendamento, bloqueio, fora do expediente ou sem profissional disponível. Consulte `/v1/availability/slots` e tente outro horário. #### `min_advance_violation` **409.** O horário pedido não respeita a antecedência mínima da organização, ou já passou. #### `professional_cannot_perform_service` **409.** O profissional indicado não executa um dos serviços pedidos. #### `appointment_not_editable` **409.** O status do agendamento não permite a ação (por exemplo, confirmar um agendamento cancelado) ou ele já tem nota fiscal emitida. #### `action_not_allowed` **409.** A ação não é permitida para este agendamento por uma regra da organização. O `detail` explica qual. #### `queue_closed` **409.** A Fila Online do profissional (ou de todos) está fechada agora. #### `deposit_required` **409.** O serviço exige sinal, que só pode ser pago pela página pública de agendamento. Entradas com sinal não são criadas pela API. #### `queue_entry_not_editable` **409.** O status da entrada na fila não permite a ação (por exemplo, chamar alguém que já foi atendido). #### `duplicate_request` **409.** A `Idempotency-Key` já foi usada nas últimas 24 horas com um corpo diferente. Gere uma chave nova para cada operação. #### `webhook_limit_reached` **409.** A organização já tem o máximo de webhooks (20). Remova um antes de criar outro. #### `webhook_inactive` **409.** O webhook está desativado. Reative-o (`active: true`) antes de testar. ### Limites e indisponibilidade #### `rate_limited` **429.** Limite de requisições estourado. Espere os segundos do header `Retry-After` e tente de novo. #### `internal_error` **500.** Erro do nosso lado. Pode tentar de novo em instantes; se persistir, fale com o suporte informando o `request_id`. #### `api_disabled` **503.** A API foi desligada temporariamente por manutenção ou incidente. Tente de novo mais tarde. #### `method_not_allowed` **405.** O método HTTP não existe para esta rota (por exemplo, `DELETE` num agendamento). Veja a [referência](https://api.guruagenda.com.br/docs/reference). --- # Referência das rotas Servidor: `https://api.guruagenda.com.br`. Toda rota exige `Authorization: Bearer ga_live_…`. Os tipos estão em notação TypeScript: `campo?` é opcional, `T | null` aceita nulo. Os erros listados em cada rota são o `code` do `problem+json` (veja o guia de erros). O OpenAPI completo, com exemplos, está em `https://api.guruagenda.com.br/v1/openapi.json`. ## Conta A organização dona da chave. É o teste de credencial do n8n. ### GET /v1/me Organização, plano e features da chave - 200: MeOut - erros: 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ## Profissionais Dono e colaboradores ativos da organização. ### GET /v1/professionals Dono e colaboradores ativos - query `service_id`: integer | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: ProfessionalList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ### GET /v1/professionals/{professional_id} Detalhe de um profissional - path `professional_id`: integer (obrigatório) - 200: ProfessionalOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ## Serviços Catálogo de serviços. ### GET /v1/services Catálogo de serviços (ativos por padrão) - query `include_inactive`: boolean — padrão false - query `specialty_id`: integer | null - query `booking_mode`: "SCHEDULED" | "QUEUE" | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: ServiceList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ### GET /v1/services/{service_id} Detalhe de um serviço - path `service_id`: integer (obrigatório) - 200: ServiceOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ## Clientes Carteira de clientes, com upsert e `metadata` para IDs externos. ### GET /v1/clients Lista e busca clientes - query `q`: string | null - query `phone`: string | null - query `email`: string | null - query `cpf`: string | null - query `updated_since`: string (date-time) | null - query `created_since`: string (date-time) | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: ClientList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ### POST /v1/clients Cria um cliente ou devolve o existente (upsert) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - corpo (JSON): ClientIn - 200: ClientOut - 201: ClientOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited`, 409 `duplicate_request`, 422 `client_identifier_required` ### GET /v1/clients/{client_id} Detalhe de um cliente - path `client_id`: integer (obrigatório) - 200: ClientOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ### PATCH /v1/clients/{client_id} Atualiza dados e metadata - path `client_id`: integer (obrigatório) - corpo (JSON): ClientPatch - 200: ClientOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ## Disponibilidade Horários livres, com as mesmas regras da página pública. ### GET /v1/availability/slots Horários livres para uma cesta de serviços - query `professional_id`: integer | null - query `date`: string (date) | null - query `from`: string (date) | null - query `to`: string (date) | null - 200: SlotList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited`, 409 `professional_cannot_perform_service` ### GET /v1/availability/next-dates Próximas datas com vaga - query `professional_id`: integer | null - query `limit`: integer — padrão 3; mín. 1; máx. 10 - 200: NextDateList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited`, 409 `professional_cannot_perform_service` ## Agendamentos Leitura, criação e ações (reagendar, cancelar, confirmar, concluir, falta). ### GET /v1/appointments Lista agendamentos - query `from`: string | null - query `to`: string | null - query `professional_id`: integer | null - query `client_id`: integer | null - query `service_id`: integer | null - query `type`: string | null - query `updated_since`: string (date-time) | null - query `created_since`: string (date-time) | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: AppointmentList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ### POST /v1/appointments Cria um agendamento (201) ou devolve o idêntico recente (200) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - corpo (JSON): AppointmentIn - 200: AppointmentOut - 201: AppointmentOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 403 `feature_not_enabled`, 403 `forbidden_overbook`, 429 `rate_limited`, 409 `slot_unavailable`, 409 `professional_cannot_perform_service`, 409 `min_advance_violation`, 409 `duplicate_request`, 422 `client_identifier_required` ### GET /v1/appointments/{appointment_id} Detalhe de um agendamento - path `appointment_id`: integer (obrigatório) - 200: AppointmentOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ### PATCH /v1/appointments/{appointment_id} Altera observações e metadata (data e status são por ação) - path `appointment_id`: integer (obrigatório) - corpo (JSON): AppointmentPatch - 200: AppointmentOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `appointment_not_editable` ### POST /v1/appointments/{appointment_id}/reschedule Move para outro horário - path `appointment_id`: integer (obrigatório) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - corpo (JSON): RescheduleIn - 200: AppointmentOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `slot_unavailable`, 409 `min_advance_violation`, 409 `appointment_not_editable`, 409 `duplicate_request` ### POST /v1/appointments/{appointment_id}/cancel Cancela pela clínica (CANCELLED_PROFESSIONAL) - path `appointment_id`: integer (obrigatório) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - corpo (JSON): CancelIn - 200: AppointmentOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `appointment_not_editable`, 409 `duplicate_request` ### POST /v1/appointments/{appointment_id}/confirm PENDING → CONFIRMED - path `appointment_id`: integer (obrigatório) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - 200: AppointmentOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `appointment_not_editable`, 409 `duplicate_request` ### POST /v1/appointments/{appointment_id}/complete CONFIRMED → COMPLETED - path `appointment_id`: integer (obrigatório) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - 200: AppointmentOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `appointment_not_editable`, 409 `duplicate_request` ### POST /v1/appointments/{appointment_id}/no-show Marca falta (dispara a mensagem de falta, como hoje) - path `appointment_id`: integer (obrigatório) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - 200: AppointmentOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `appointment_not_editable`, 409 `duplicate_request` ## Fila Online Entradas da fila do dia. Exige a feature `online_queue`. ### GET /v1/queue/entries Entradas da fila, por ordem de chegada - query `professional_id`: integer | null - query `date`: string (date) | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: QueueEntryList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ### POST /v1/queue/entries Põe o cliente na fila (201) ou devolve a entrada ativa dele hoje (200) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - corpo (JSON): QueueEntryIn - 200: QueueEntryOut - 201: QueueEntryOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited`, 409 `queue_closed`, 409 `deposit_required`, 409 `professional_cannot_perform_service`, 409 `duplicate_request`, 422 `client_identifier_required` ### GET /v1/queue/entries/{entry_id} Detalhe de uma entrada - path `entry_id`: integer (obrigatório) - 200: QueueEntryOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ### POST /v1/queue/entries/{entry_id}/call Chama o cliente (avisa com o link) - path `entry_id`: integer (obrigatório) - corpo (JSON): QueueActionIn | null - 200: QueueEntryOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `queue_entry_not_editable` ### POST /v1/queue/entries/{entry_id}/complete Conclui o atendimento - path `entry_id`: integer (obrigatório) - 200: QueueEntryOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `queue_entry_not_editable` ### POST /v1/queue/entries/{entry_id}/skip Pula o cliente - path `entry_id`: integer (obrigatório) - 200: QueueEntryOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `queue_entry_not_editable` ### POST /v1/queue/entries/{entry_id}/cancel Cancela a entrada - path `entry_id`: integer (obrigatório) - 200: QueueEntryOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `queue_entry_not_editable` ## Sinais Sinais (PIX) de agendamentos e da fila. Só leitura. ### GET /v1/deposits Lista os sinais (do mais antigo para o mais novo) - query `from`: string | null - query `to`: string | null - query `appointment_id`: integer | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: DepositList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ### GET /v1/deposits/{deposit_id} Detalhe de um sinal - path `deposit_id`: integer (obrigatório) - 200: DepositOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ## Eventos Os mesmos eventos entregues nos webhooks, para polling (30 dias). ### GET /v1/events Eventos dos últimos 30 dias, em ordem de criação - query `after`: string | null - query `since`: string (date-time) | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: EventList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ### GET /v1/events/{event_id} Detalhe de um evento - path `event_id`: string (obrigatório) - 200: EventOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ## Webhooks Assinaturas de webhook, teste e histórico de entregas. Escopo `webhooks:manage`. ### GET /v1/webhooks Lista as assinaturas (filtro ?url= para o checkExists) - query `url`: string | null - query `active`: boolean | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: WebhookList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited` ### POST /v1/webhooks Cria uma assinatura (o segredo volta só aqui) - header `Idempotency-Key`: string — Até 255 caracteres. Mesma chave e mesmo corpo em 24 h devolvem a resposta original; corpo diferente devolve `409 duplicate_request`.; máx. caracteres 255 - corpo (JSON): WebhookIn - 201: WebhookOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 429 `rate_limited`, 409 `webhook_limit_reached`, 409 `duplicate_request`, 422 `template_invalid` ### GET /v1/webhooks/{webhook_id} Detalhe (sem o segredo) - path `webhook_id`: string (obrigatório) - 200: WebhookOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ### PATCH /v1/webhooks/{webhook_id} Edita (URL validada de novo) - path `webhook_id`: string (obrigatório) - corpo (JSON): WebhookPatch - 200: WebhookOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 422 `template_invalid` ### DELETE /v1/webhooks/{webhook_id} Remove a assinatura - path `webhook_id`: string (obrigatório) - 204: sem corpo - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ### POST /v1/webhooks/{webhook_id}/rotate-secret Gira o segredo (o antigo vale como v0 por 24 h) - path `webhook_id`: string (obrigatório) - corpo (JSON): RotateSecretIn - 200: WebhookOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ### POST /v1/webhooks/{webhook_id}/test Envia um `ping` agora e devolve o resultado da entrega - path `webhook_id`: string (obrigatório) - 200: DeliveryOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited`, 409 `webhook_inactive` ### GET /v1/webhooks/{webhook_id}/deliveries Histórico de entregas (30 dias), mais recentes primeiro - path `webhook_id`: string (obrigatório) - query `status`: string | null - query `limit`: integer — padrão 50; mín. 1; máx. 200 - query `cursor`: string | null - 200: DeliveryList - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ### POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver Reenvia o evento desta entrega (nova entrega, em background) - path `webhook_id`: string (obrigatório) - path `delivery_id`: string (obrigatório) - 202: DeliveryOut - erros: 400 `validation_error`, 401 `unauthorized`, 403 `forbidden_scope`, 404 `not_found`, 429 `rate_limited` ## Schemas ```ts ApiKeyOut { id: string name: string scopes: string[] } MeOut { object?: "organization" // padrão "organization" id: integer name: string slug: string | null timezone: string plan: PlanOut | null features: {[key: string]: boolean} api_key: ApiKeyOut whatsapp: WhatsAppOut } PlanOut { name: string } WhatsAppOut { channel?: "own" | "central" | null status?: string | null phone?: string | null } MiniRef { id: integer name: string } ProfessionalList { has_more: boolean next_cursor?: string | null next?: string | null data: ProfessionalOut[] } ProfessionalOut { object: "professional" id: integer name: string email: string | null role: "owner" | "collaborator" is_active: boolean registry: string | null service_ids: integer[] services?: ServiceBase[] | null } ServiceBase { object: "service" id: integer name: string description: string | null specialty: MiniRef | null service_type: "IN_PERSON" | "TELEMEDICINE" booking_mode: "SCHEDULED" | "QUEUE" duration_minutes: integer price: string | null currency: "BRL" color: string | null is_active: boolean hidden_from_public_booking: boolean max_capacity: integer professional_ids: integer[] created_at: string updated_at: string } ProfessionalBase { object: "professional" id: integer name: string email: string | null role: "owner" | "collaborator" is_active: boolean registry: string | null service_ids: integer[] } ServiceList { has_more: boolean next_cursor?: string | null next?: string | null data: ServiceOut[] } ServiceOut { object: "service" id: integer name: string description: string | null specialty: MiniRef | null service_type: "IN_PERSON" | "TELEMEDICINE" booking_mode: "SCHEDULED" | "QUEUE" duration_minutes: integer price: string | null currency: "BRL" color: string | null is_active: boolean hidden_from_public_booking: boolean max_capacity: integer professional_ids: integer[] created_at: string updated_at: string professionals?: ProfessionalBase[] | null } Address { cep?: string | null street?: string | null number?: string | null complement?: string | null neighborhood?: string | null city?: string | null state?: string | null } ClientLinks { self: string app: string } ClientList { has_more: boolean next_cursor?: string | null next?: string | null data: ClientOut[] } ClientOut { object: "client" id: integer name: string first_name: string | null last_name: string | null email: string | null phone: string | null cpf: string | null is_company: boolean cnpj: string | null company_name: string | null date_of_birth: string | null gender: string | null address: Address medical_notes: null metadata: {[key: string]: string} stats: ClientStats created_at: string updated_at: string links: ClientLinks } ClientStats { appointments_total: integer last_appointment_at: string | null no_show_count: integer } ClientIn { email?: string | null phone?: string | null cpf?: string | null is_company?: boolean | null cnpj?: string | null company_name?: string | null date_of_birth?: string (date) | null gender?: "M" | "F" | "O" | "P" | null address?: Address | null metadata?: {[key: string]: string} | null first_name: string // mín. caracteres 1; máx. caracteres 150 last_name: string // mín. caracteres 1; máx. caracteres 150 match_on?: ("email" | "phone" | "cpf")[] // padrão ["email", "phone", "cpf"] on_match?: "return" | "update" // padrão "return" } ClientPatch { email?: string | null phone?: string | null cpf?: string | null is_company?: boolean | null cnpj?: string | null company_name?: string | null date_of_birth?: string (date) | null gender?: "M" | "F" | "O" | "P" | null address?: Address | null metadata?: {[key: string]: string} | null first_name?: string | null last_name?: string | null } Slot { start: string end: string professional_id: integer } SlotList { timezone: string data: Slot[] } NextDate { date: string slot_count: integer first_slot: string } NextDateList { timezone: string data: NextDate[] } AppointmentLinks { self: string app: string client_portal: string | null meet: string | null } AppointmentList { has_more: boolean next_cursor?: string | null next?: string | null data: AppointmentOut[] } AppointmentOut { object: "appointment" id: integer status: string status_label: string type: string start: string end: string timezone: string duration_minutes: integer professional: ProfessionalOut | ProfessionalMini professional_id: integer client: ClientOut | ClientMini client_id: integer services: (ServiceOut | ServiceItem)[] service_ids: integer[] price: Price deposit: DepositMini | null invoice_status: string notes_client: string | null notes_professional: string | null source: string recurrence_id: integer | null google_event_id: string | null metadata: {[key: string]: string} links: AppointmentLinks created_at: string updated_at: string } ClientMini { id: integer name: string phone: string | null email: string | null } DepositMini { id: integer status: string amount: string | null paid_at: string | null } Price { total: string | null discount: string | null currency: "BRL" coupon_code: string | null } ProfessionalMini { id: integer name: string } ServiceItem { id: integer name: string price: string | null duration_minutes: integer } AppointmentIn { service_ids: integer[] // mín. itens 1 start: string (date-time) professional_id?: integer | null client_id?: integer | null client?: ClientIn | null status?: "CONFIRMED" | "PENDING" | null notes_client?: string | null notes_professional?: string | null allow_overbook?: boolean // padrão false notify_client?: boolean // padrão true source?: string | null metadata?: {[key: string]: string} | null } AppointmentPatch { notes_client?: string | null notes_professional?: string | null metadata?: {[key: string]: string} | null } RescheduleIn { start: string (date-time) notify_client?: boolean // padrão false } CancelIn { reason?: string | null notify_client?: boolean // padrão false } DepositAppointment { id: integer start: string | null status: string professional: MiniRef client: MiniRef } DepositLinks { self: string } DepositList { has_more: boolean next_cursor?: string | null next?: string | null data: DepositOut[] } DepositOut { object: string id: integer status: string amount: string | null currency: string split: DepositSplit paid_at?: string | null appointment_id?: integer | null appointment?: DepositAppointment | null queue_entry_id?: integer | null payment: DepositPayment created_at: string updated_at: string links: DepositLinks } DepositPayment { method: string pix_copy_paste?: string | null expires_at?: string | null } DepositSplit { professional: string | null platform: string | null } QueueClientMini { id: integer name: string phone: string | null email: string | null } QueueEntryList { has_more: boolean next_cursor?: string | null next?: string | null data: QueueEntryOut[] } QueueEntryOut { object: string id: integer status: string status_label: string position?: integer | null assignment_mode: string professional: MiniRef professional_id: integer client: QueueClientMini client_id: integer service: MiniRef service_id: integer notes_client?: string | null deposit_id?: integer | null joined_at?: string | null called_at?: string | null call_deadline?: string | null finished_at?: string | null created_at: string updated_at: string links: QueueLinks } QueueLinks { self: string } QueueEntryIn { service_id: integer professional_id?: integer | null client_id?: integer | null client?: ClientIn | null notes_client?: string | null notify_client?: boolean // padrão true } QueueActionIn { notify_client?: boolean // padrão true } EventActor { type: string id?: any | null name?: string | null } EventContextOut { actor: EventActor source?: string | null request_id?: string | null } EventList { has_more: boolean next_cursor?: string | null next?: string | null data: EventOut[] } EventOut { id: string type: string api_version: string created_at: string organization_id: integer livemode: boolean data: object context: EventContextOut } WebhookCreator { type: string id?: any | null } WebhookFilters { professional_ids: integer[] service_ids: integer[] statuses: string[] types: string[] sources: string[] } WebhookList { has_more: boolean next_cursor?: string | null next?: string | null data: WebhookOut[] } WebhookOut { object: string id: string name: string url: string events: string[] filters: WebhookFilters format: string template?: object | null message_template?: string | null headers: {[key: string]: string} secret_names: string[] show_full_personal_data: boolean secret?: string | null secret_rotating_until?: string | null api_version: string active: boolean status: string failure_count: integer last_delivery_at?: string | null last_success_at?: string | null created_by?: WebhookCreator | null created_at: string updated_at: string } WebhookIn { name: string url: string events: string[] filters?: object | null format?: string | null headers?: object | null secret?: string | null api_version?: string | null active?: boolean | null show_full_personal_data?: boolean | null template?: object | null message_template?: string | null secrets?: object | null } WebhookPatch { name?: string | null url?: string | null events?: string[] | null filters?: object | null format?: string | null headers?: object | null api_version?: string | null active?: boolean | null show_full_personal_data?: boolean | null template?: object | null message_template?: string | null secrets?: object | null } RotateSecretIn { secret?: string | null } DeliveryOut { object: string id: string webhook_id: string event_id: string event_type: string status: string attempt: integer next_attempt_at?: string | null response_status?: integer | null response_excerpt?: string | null duration_ms?: integer | null error?: string | null request_headers: {[key: string]: string} created_at: string updated_at: string } DeliveryList { has_more: boolean next_cursor?: string | null next?: string | null data: DeliveryOut[] } // Erro em `application/problem+json` (RFC 9457). Problem { type: string (uri) title: string status: integer code: string // Estável, em inglês. Trate o erro por ele. detail: string errors: object[] request_id: string | null // Informe ao suporte. } ```