A API v1 deixa o site da clínica e o chatbot de IA do parceiro conduzirem a agenda online da clínica: listar especialidades, profissionais, procedimentos e horários livres, reservar e acompanhar a reserva. O site e o chatbot usam a mesma API e a mesma credencial. A reserva segue as mesmas regras da agenda online da clínica.
Endereço base: https://api.clinideck.com.br/api/integration/v1
1. Credencial
São duas peças, entregues por links de visualização única:
| Peça | O que é | Quantas |
|---|---|---|
Token do integrador (cdk_int_...) | identifica o seu software | um por integrador |
| Login e senha da integração | identificam a clínica que autorizou você | um par por clínica |
Toda chamada leva os quatro headers:
Authorization: Bearer <token do integrador>
X-Integration-Type: AGENDA_ONLINE
X-Integration-Login: <login da clínica>
X-Integration-Password: <senha da clínica>A clínica sai do login e da senha, nunca do corpo nem da URL. O token sozinho não abre nada, e a senha de uma clínica não alcança outra.
Como a credencial chega
- O CliniDeck cadastra o seu software com o nome, o CNPJ ou CPF e o e-mail técnico.
- O e-mail técnico recebe um link de confirmação, que vale 48 horas. Abrir o link não confirma; quem confirma é o botão da página.
- Confirmado o e-mail, ele recebe o termo de uso da API de integração. O link do termo vale 7 dias. Leia as páginas em ordem, até a última.
- Na hora de assinar, peça o código: ele vai ao mesmo e-mail técnico e vale 10 minutos. Informe o seu nome completo e o cargo, marque os itens do termo e digite o código. São até 5 códigos e 5 tentativas por link; esgotou, peça ao CliniDeck um link novo, que invalida o anterior.
- Assinado o termo, o e-mail técnico recebe dois e-mails: a cópia em PDF, com o certificado da assinatura, e o link do token do integrador. Sem a assinatura, não existe token.
- Cada clínica autoriza o seu software na tela dela, aceitando o termo de compartilhamento da clínica. O login e a senha daquela clínica chegam ao mesmo e-mail técnico, por outro link de visualização única. A clínica nunca vê a senha.
Os links de visualização única valem 48 horas e só se consomem pelo botão da página: abrir o link (inclusive o robô do antivírus ou a prévia do aplicativo de mensagens) não gasta nada. Vencido o link, peça ao CliniDeck um novo. Uma versão nova do termo de uso não derruba o token nem as clínicas já autorizadas.
Quando o CliniDeck emite um token ou uma senha nova no lugar da atual, a anterior segue aceita por 7 dias: troque-a no seu servidor dentro desse prazo.
Credencial ausente, inválida, revogada ou sem permissão para a rota recebe sempre a mesma resposta:
HTTP/1.1 401 Unauthorized
{ "code": "integration.unauthorized", "message": "Invalid or missing integration credentials." }Guarde a credencial num cofre de segredos do seu servidor. Ela nunca vai para o navegador do paciente nem para o prompt do modelo de IA: quem chama a API é o seu backend.
2. Respostas nunca entram em cache
Toda resposta, inclusive a de erro, sai com:
Cache-Control: no-store, private
Pragma: no-cache
Vary: Authorization, X-Integration-Type, X-Integration-Login, X-Integration-Password, Idempotency-KeyA URL é a mesma para todas as clínicas: o que muda são os headers. Não ponha CDN nem cache na frente destas chamadas.
3. Rotas
| Rota | Para quê |
|---|---|
GET /specialties | especialidades atendidas pela agenda online da clínica |
GET /professionals?specialty= | profissionais agendáveis, com filtro opcional por especialidade (ignora maiúsculas e acentos) |
GET /procedures?professionalId= | procedimentos do profissional, com preço em centavos e duração |
GET /availability?professionalId=&month=YYYY-MM | horários livres do mês, no fuso da clínica (sem month, o mês corrente) |
POST /bookings | reserva um horário |
GET /bookings/{id} | situação de uma reserva que você criou |
Cada horário de availability traz startsAt (instante UTC, o valor que a reserva recebe), time (hora local da clínica), durationMinutes e group. bookingEnabled: false quer dizer que a clínica não recebe reserva agora, e days vem vazio.
O profissional traz as restrições que a reserva confere: idade mínima e máxima, sexos atendidos, antecedência mínima em minutos e horizonte máximo em dias (0 é sem limite). Use-as para não oferecer o que vai ser recusado.
4. Reservar
POST /bookings
Idempotency-Key: 7f9c2b1e-4a1d-4f3e-9c55-2d8f0b6a1e10
{
"patient": {
"name": "Nome da paciente",
"document": "CPF só com números",
"birthDate": "1990-05-20",
"phone": "47999999999",
"email": "[email protected]"
},
"professionalId": "<id do profissional>",
"procedureId": "<id do procedimento>",
"startsAt": "<startsAt de um horário de /availability>"
}O paciente novo leva name, email, document, birthDate e phone, todos obrigatórios. Os que faltarem entre name, email, document e phone voltam juntos numa recusa só, com o caminho de cada um:
{ "code": "validation_failed", "message": "Patient email, phone are required.", "details": { "fields": [{ "field": "patient.email", "rules": ["isNotEmpty"] }, { "field": "patient.phone", "rules": ["isNotEmpty"] }] } }A birthDate ausente ou inválida recebe 400 publicCheckout.patientBirthDateRequired, nos dois modos. O gender (MALE, FEMALE ou OTHER) é opcional, mas obrigatório quando o profissional restringe os sexos atendidos: sem ele, a recusa é 409 publicCheckout.genderNotAttended.
O resultado depende da regra da clínica:
- A clínica exige pagamento (o padrão): a reserva nasce
AWAITING_PAYMENTcom ocheckoutUrl. Envie o link à paciente. Sem pagamento no prazo, a reserva é cancelada e o horário volta à grade. - A clínica não exige pagamento: a reserva nasce
PENDING_CONFIRMATION, sem link.
Uma reserva por checkout. Cada POST /bookings reserva um horário e gera, quando a clínica exige pagamento, um link próprio. Pacote de sessões se reserva pela 1ª sessão; as demais a clínica combina com a paciente.
Paciente que já é da clínica
Mande useExistingPatient: true, o CPF e a data de nascimento:
{ "patient": { "useExistingPatient": true, "document": "CPF só com números", "birthDate": "1990-05-20" }, "...": "..." }CPF desconhecido e data que não confere recebem a mesma recusa, 400 publicCheckout.existingPatientNotFound. A API não diz se alguém é paciente da clínica. Depois de 10 tentativas com o mesmo CPF em 24 horas, a recusa passa a ser 429 publicCheckout.existingPatientTooManyAttempts, valha a data certa ou não.
Regras que a reserva confere
| Recusa | Código |
|---|---|
| menor de 18 anos | 400 publicCheckout.patientUnder18 |
| idade ou sexo fora do que o profissional atende | 409 publicCheckout.ageNotAttended, 409 publicCheckout.genderNotAttended |
| horário fora da antecedência mínima | 409 publicCheckout.minBookingNotice |
| horário além do horizonte do profissional | 409 publicCheckout.maxBookingHorizon |
| horário tomado ou fora da grade | 400 appointment.slotUnavailable |
| profissional que não é da agenda online da clínica | 404 integration.professionalNotFound |
clínica que exige pagamento e não tem como cobrar agora (bookingEnabled: false em availability) | 400 publicCheckout.paymentsNotConfigured |
| clínica que não recebe reserva online agora | 409 onlineBooking.unavailable (vale para toda rota, menos GET /bookings/{id}) |
| outra reserva da mesma credencial ainda em curso | 409 integration.bookingBusy: tente de novo em instantes, com a mesma Idempotency-Key |
5. Idempotency-Key
Obrigatória em todo POST: de 1 a 255 caracteres ASCII visíveis. Um UUID novo por reserva pretendida serve.
| Situação | Resposta |
|---|---|
| sem a chave, ou malformada | 400 idempotency.keyRequired, 400 idempotency.keyInvalid |
| a mesma chave com o mesmo corpo | a mesma reserva, com o status original e o header Idempotent-Replayed: true |
| a mesma chave com outro corpo | 422 idempotency.keyReused |
| a mesma chave enquanto o 1º pedido ainda corre | 409 idempotency.inProgress: tente de novo em instantes |
| o pedido falhou (horário tomado, validação, indisponível) | a chave fica livre para uma nova tentativa |
A chave que gerou uma reserva fica presa a ela para sempre: não reuse chave entre reservas. A resposta repetida traz a situação ATUAL da reserva, não a do momento da criação.
Ao perder a resposta (timeout, queda de rede), refaça o pedido com a MESMA chave e o MESMO corpo: você recebe a reserva que já existe, sem criar outra.
6. Situação da reserva
GET /bookings/{id} só enxerga as reservas que a sua credencial criou. O id de outra clínica, de outro integrador ou da recepção recebe o mesmo 404 integration.bookingNotFound de um id que não existe. A consulta segue respondendo quando a clínica deixa de receber reserva online.
status | Quer dizer |
|---|---|
AWAITING_PAYMENT | espera o pagamento; checkoutUrl vem preenchido |
PENDING_CONFIRMATION | a clínica não exige pagamento e ainda não confirmou |
CONFIRMED | confirmada (paga, confirmada pela clínica ou em atendimento) |
CANCELED | cancelada, vencida sem pagamento ou excluída |
COMPLETED | atendimento finalizado |
NO_SHOW | a paciente não compareceu |
Para saber quando a situação muda sem consultar, use o webhook (seção 10). A consulta conta no teto de leitura.
7. Limites
| Teto | Valor | Recusa |
|---|---|---|
| leituras por credencial | 300, repondo 5 por segundo | 429 rate_limited |
| reservas por credencial | 30, repondo 1 a cada 2 segundos | 429 rate_limited |
| credenciais recusadas por IP | 120, repondo 2 por segundo | 429 rate_limited |
| reservas em aberto por credencial | 20 | 409 integration.pendingLimitReached |
O teto é por credencial: as clínicas que você atende têm tetos separados, mesmo saindo do mesmo servidor. Todo 429 e 503 traz o header Retry-After e details.retryAfterSeconds, em segundos.
Reserva em aberto é a que espera pagamento ou a que, sem cobrança, ainda está pendente com o horário no futuro. Ela deixa de contar quando é paga, confirmada, cancelada ou vence.
Reservar depende de um serviço interno que, fora do ar, devolve 503 integration.unavailable. As leituras seguem respondendo. Refaça a reserva depois do Retry-After, com a mesma Idempotency-Key.
8. Erros
Todo erro sai no mesmo envelope:
{ "code": "validation_failed", "message": "...", "details": { "fields": [{ "field": "patient.document", "rules": ["isNotEmpty"] }] } }codeé estável e é por ele que o seu código decide. Amessagepode mudar.detailssó vem quando há o que detalhar:fields(a validação),retryAfterSeconds(os tetos) esupportId(o erro interno, para o atendimento achar o caso).- Campo que a API não conhece no corpo ou na query é recusado (
validation_failed). - Corpo acima de 10 MB é recusado antes de chegar à API, com
413sem corpo, fora deste envelope. - Os códigos genéricos por status são
bad_request,unauthorized,not_found,conflict,payload_too_large,unprocessable,rate_limited,unavailableeinternal_error. Os códigos de domínio estão nas seções acima.
9. O fluxo do chatbot
GET /specialties: "Qual especialidade você procura?"GET /professionals?specialty=<nome>: lista quem atende, para a pessoa escolher.GET /procedures?professionalId=<id>: escolhe o atendimento, com preço e duração.GET /availability?professionalId=<id>&month=<YYYY-MM>: oferece os horários; use otimepara falar e guarde ostartsAt.- Colete nome, e-mail, CPF, data de nascimento e telefone, e o sexo quando o profissional restringe os sexos atendidos. Se a pessoa disser que já é paciente, peça só o CPF e a data de nascimento e mande
useExistingPatient: true. POST /bookingscom umaIdempotency-Keynova.- Com
AWAITING_PAYMENT, envie ocheckoutUrle avise que o horário só fica garantido com o pagamento. GET /bookings/{id}para responder "minha consulta está confirmada?".
O modelo de IA nunca recebe a credencial: ele pede ao seu backend, e o backend chama a API.
10. Avisos por webhook
O CliniDeck avisa o seu servidor quando uma reserva que a sua credencial criou muda:
| Evento | Quando |
|---|---|
booking.confirmed | a reserva saiu de pendente para confirmada (paga, confirmada pela clínica ou em atendimento), ou a clínica reativou uma reserva cancelada |
booking.canceled | a reserva foi cancelada, venceu sem pagamento ou foi excluída |
payment.confirmed | a cobrança da reserva foi paga |
O pagamento da reserva costuma gerar dois avisos: payment.confirmed e booking.confirmed. A criação pelo POST /bookings não gera aviso, porque a resposta já traz a situação.
Como ligar
- Passe ao CliniDeck a URL que recebe os avisos. É uma por integrador, para todas as clínicas que você atende. Ela precisa ser
https://, num endereço público, sem usuário, senha nem#. - A chave de assinatura (
cdk_whsec_...) chega ao e-mail técnico por um link de visualização única, como o token. Ela só existe depois do termo assinado. Guarde-a no cofre de segredos do seu servidor. - Os avisos começam a sair com a URL e a chave cadastradas.
O pedido
POST <sua URL>
Content-Type: application/json
User-Agent: CliniDeck-Webhooks/1.0
X-CliniDeck-Event-Id: 01970000-0000-7000-8000-000000000001
X-CliniDeck-Event-Type: booking.canceled
X-CliniDeck-Signature: t=1792771200,v1=5f2b...
{"id":"01970000-0000-7000-8000-000000000001","type":"booking.canceled","occurredAt":"2026-10-23T18:00:00.000Z","company":{"id":"..."},"integration":{"id":"...","login":"clinica-demo"},"data":{"booking":{"id":"...","status":"CANCELED"}}}idé o do aviso. Ele se repete em toda tentativa do MESMO aviso: guarde os ids já processados e descarte a repetição.company.ideintegration.logindizem de qual clínica é o aviso.data.booking.idé o mesmo id doPOST /bookings.- Nos eventos da reserva,
data.booking.statuséCONFIRMEDouCANCELED. Nopayment.confirmed, vemdata.payment.statusigual aPAID. - O aviso traz só ids e estados, nunca nome, documento, telefone ou dado de saúde. Para o resto,
GET /bookings/{id}. - A ordem de chegada não é garantida: decida pelo
occurredAte, na dúvida, consulte a reserva.
Conferir a assinatura
O header é t=<instante do envio em segundos>,v1=<assinatura>, e pode trazer mais de um v1=. Cada v1 é o HMAC-SHA256, em hexadecimal, de <t>.<corpo>, com a chave como texto. Confira sobre os BYTES do corpo como chegaram, antes de interpretar o JSON: reserializar muda a ordem ou o espaçamento e a assinatura deixa de bater.
import { createHmac, timingSafeEqual } from 'node:crypto';
function isValidCliniDeckSignature(rawBody, header, signingKey, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = header.split(',');
const t = Number(parts.find((part) => part.startsWith('t='))?.slice(2));
if (!Number.isInteger(t) || Math.abs(nowSeconds - t) > 300) return false;
const expected = createHmac('sha256', signingKey).update(`${t}.${rawBody}`, 'utf8').digest();
return parts
.filter((part) => part.startsWith('v1='))
.some((part) => {
const received = Buffer.from(part.slice(3), 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}- Aceite o pedido se QUALQUER
v1=casar. Quando o CliniDeck gera uma chave nova, a anterior assina junto por 7 dias: troque a chave no seu servidor dentro desse prazo. - Recuse
tcom mais de 300 segundos de diferença do seu relógio: é a defesa contra a repetição de um pedido capturado. - Cada tentativa é assinada de novo, com o
tdaquele envio: a mesma repetição chega com outra assinatura.
Responder
- Responda
2xxem até 10 segundos e processe depois, numa fila sua. Qualquer outra resposta, o redirecionamento (3xx), o tempo esgotado e a falha de conexão contam como falha da tentativa. - A falha volta a ser tentada em 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 6 h e 12 h: são 10 tentativas em cerca de 26 horas. Aviso com mais de 72 horas não é mais enviado; reconcilie pelo
GET /bookings/{id}. - Desligamento: 20 avisos seguidos que esgotam as 10 tentativas desligam o webhook, e o e-mail técnico recebe o aviso. Uma tentativa que falha não conta sozinha: o seu deploy ou uma queda de algumas horas não desliga nada. Um aviso entregue zera a conta. Enquanto desligado, os avisos não saem e são descartados: religar não reenvia o que ficou para trás. Corrija o recebedor, peça ao CliniDeck para religar e reconcilie pelo
GET /bookings/{id}. - A clínica que revoga a sua integração para de gerar avisos, e os pendentes dela são descartados.
11. Fora da v1
- Consultar, cancelar ou remarcar reservas de uma paciente: isso é da área do paciente.
- Avisos de outros eventos além dos três da seção 10.
- Paciente estrangeiro sem CPF.
- Mais de um horário por reserva.