{"openapi":"3.1.0","info":{"title":"Almada Care — API de contacto","version":"1","description":"API pública e sem autenticação para submeter um pedido de contacto/marcação da clínica Almada Care. Não há chave de API nem OAuth: o único requisito é o token de uso único devolvido por GET /api/contacto, que serve apenas para impedir automatismos, não como identidade de chamador. Abuso é mitigado com limites por IP e por contacto (telefone/email), um honeypot e moderação de conteúdo — ver as respostas 429 e os exemplos de recusa silenciosa abaixo. Para ler o catálogo da clínica (especialidades, serviços, corpo clínico, horários) um agente deve usar /llms.txt ou pedir as páginas do site com `Accept: text/markdown`; esta API não expõe esses dados. Qualquer caminho sob /api que não seja um dos documentados aqui devolve 404 com o mesmo formato de erro (código `not_found`), nunca uma página HTML. Versionamento: `info.version` é um único inteiro em texto, igual ao cabeçalho `API-Version` de toda resposta sob /api/** — sobe só em mudanças que quebrem compatibilidade (ver a secção Versioning de /api/docs); não existe `/api/v1/`, `/api/v2/`, etc.","contact":{"name":"Almada Care","email":"geral@almadacare.pt","url":"https://almadacare.pt"}},"servers":[{"url":"https://almadacare.pt","description":"Produção"}],"paths":{"/api/contacto":{"get":{"operationId":"getContactFormToken","summary":"Obter um token de formulário de uso único","description":"Devolve um token assinado, válido uma única vez, que tem de ser enviado no campo `token` do POST seguinte. Sem chamar este endpoint primeiro, o POST recusa-se silenciosamente (200 `{ ok: true, delivered: false }`, sem `error`) — pedir sempre um token nesta rota antes de submeter um pedido de contacto. Esta rota tem o seu próprio limite de pedidos (ver 429).","responses":{"200":{"description":"Token emitido. Nunca é guardado em cache — cada chamada devolve um token novo.","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."},"RateLimit-Limit":{"schema":{"type":"integer","minimum":1},"description":"Pedidos permitidos na janela do orçamento que decidiu esta resposta."},"RateLimit-Remaining":{"schema":{"type":"integer","minimum":0},"description":"Pedidos ainda disponíveis nessa janela, já contando este pedido."},"RateLimit-Reset":{"schema":{"type":"integer","minimum":0},"description":"Segundos até o pedido mais antigo ainda contabilizado nessa janela expirar (delta-seconds, não um timestamp) — o instante em que pelo menos mais um pedido volta a caber."},"Cache-Control":{"schema":{"type":"string","enum":["no-store"]},"description":"Sempre `no-store`: um token reutilizado por um cache seria recusado no POST (409)."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormToken"},"example":{"token":"eyJhbGciOiJIUzI1NiJ9.…"}}}},"429":{"description":"Demasiados pedidos de token deste IP na última hora.","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."},"RateLimit-Limit":{"schema":{"type":"integer","minimum":1},"description":"Pedidos permitidos na janela do orçamento que decidiu esta resposta."},"RateLimit-Remaining":{"schema":{"type":"integer","minimum":0},"description":"Pedidos ainda disponíveis nessa janela, já contando este pedido."},"RateLimit-Reset":{"schema":{"type":"integer","minimum":0},"description":"Segundos até o pedido mais antigo ainda contabilizado nessa janela expirar (delta-seconds, não um timestamp) — o instante em que pelo menos mais um pedido volta a caber."},"Retry-After":{"schema":{"type":"integer","minimum":1},"description":"Segundos a esperar antes de tentar de novo."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"rate_limited":{"summary":"Too many requests.","value":{"ok":false,"error":"rate_limited","message":"Too many requests.","hint":"Wait for the duration in the Retry-After header (seconds), then retry."}}}}}}}},"post":{"operationId":"submitContactLead","summary":"Submeter um pedido de contacto/marcação","description":"Envia um pedido de contacto para a receção da clínica (e, quando há email e o conteúdo não é marcado como suspeito, uma confirmação automática ao remetente). Requer um `token` obtido em GET /api/contacto e pelo menos um de `phone`/`email`. Aceita um cabeçalho `Idempotency-Key` opcional (ver o parâmetro abaixo) para tornar retentativas seguras sem duplicar o envio. Várias recusas são deliberadamente silenciosas — respondem 200 `{ ok: true, delivered: false }`, sem `error` — precisamente para não dar a um bot sinal de qual camada o travou: o campo `company` preenchido (honeypot), um `token` inválido/em falta (distinto de expirado, que é 409), conteúdo rejeitado pela moderação, ou o mesmo pedido repetido pouco depois. Todas as respostas `ok: false` incluem `message` (frase curta em inglês) e `hint` (como resolver).","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":200},"description":"Opcional. Repetir o POST com a mesma chave dentro de 24h devolve a mesma resposta de sucesso sem reenviar nenhum email — mas só depois de um envio ter mesmo sucedido: uma tentativa que falhou (ex. 502 `send_failed`) pode ser repetida a sério com a mesma chave. A resposta de repetição vem sem `confirmed` (nada foi reconfirmado nessa chamada). Estado guardado só na memória da instância — perde-se num cold start, tal como os orçamentos de rate limit (ver `lib/rate-limit.ts`)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ContactLead"},{"anyOf":[{"required":["phone"]},{"required":["email"]}],"description":"Pelo menos um contacto — `phone` ou `email` — é obrigatório."}]},"example":{"name":"Maria Silva","phone":"+351 912 345 678","message":"Gostaria de marcar uma consulta de Clínica Geral.","locale":"pt","token":"eyJhbGciOiJIUzI1NiJ9.…"}}}},"responses":{"200":{"description":"Pedido aceite (`delivered: true`) ou recusado em silêncio (`delivered: false`, sem `error` — honeypot, token inválido, moderação ou duplicado; ver a descrição do endpoint). Uma repetição com o mesmo `Idempotency-Key` de um pedido já entregue também responde `delivered: true`, sem `confirmed` e sem reenviar nada.","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."},"RateLimit-Limit":{"schema":{"type":"integer","minimum":1},"description":"Pedidos permitidos na janela do orçamento que decidiu esta resposta."},"RateLimit-Remaining":{"schema":{"type":"integer","minimum":0},"description":"Pedidos ainda disponíveis nessa janela, já contando este pedido."},"RateLimit-Reset":{"schema":{"type":"integer","minimum":0},"description":"Segundos até o pedido mais antigo ainda contabilizado nessa janela expirar (delta-seconds, não um timestamp) — o instante em que pelo menos mais um pedido volta a caber."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactResult"},"examples":{"delivered":{"summary":"Email enviado à receção","value":{"ok":true,"delivered":true,"confirmed":true}},"silentlyDropped":{"summary":"Recusa deliberada (honeypot/token/moderação/duplicado)","value":{"ok":true,"delivered":false}},"idempotentReplay":{"summary":"Repetição com o mesmo Idempotency-Key de um pedido já entregue","value":{"ok":true,"delivered":true}}}}}},"400":{"description":"Corpo do pedido não é JSON válido.","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"invalid_json":{"summary":"The request body is not valid JSON.","value":{"ok":false,"error":"invalid_json","message":"The request body is not valid JSON.","hint":"Send a JSON object with Content-Type: application/json."}}}}}},"403":{"description":"Origem do pedido não corresponde ao anfitrião (POST cross-site).","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"forbidden":{"summary":"Cross-origin submissions are rejected.","value":{"ok":false,"error":"forbidden","message":"Cross-origin submissions are rejected.","hint":"Submit the POST from the same origin as the page (matching Origin and Host headers)."}}}}}},"409":{"description":"O token expirou ou já foi usado — pedir um novo em GET /api/contacto.","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"stale_token":{"summary":"The form token has expired or was already used.","value":{"ok":false,"error":"stale_token","message":"The form token has expired or was already used.","hint":"Call GET /api/contacto for a fresh token, then retry the POST with it."}}}}}},"413":{"description":"Corpo do pedido maior do que o limite (16000 bytes).","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"too_large":{"summary":"The request body exceeds the size limit.","value":{"ok":false,"error":"too_large","message":"The request body exceeds the size limit.","hint":"Keep the request body under 16000 bytes."}}}}}},"422":{"description":"Campo obrigatório em falta ou inválido.","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"missing_name":{"summary":"The 'name' field is required.","value":{"ok":false,"error":"missing_name","message":"The 'name' field is required.","hint":"Include a non-empty 'name' string (max 120 characters)."}},"missing_contact":{"summary":"At least one contact method is required.","value":{"ok":false,"error":"missing_contact","message":"At least one contact method is required.","hint":"Include 'phone' or 'email' (or both)."}},"invalid_email":{"summary":"The 'email' field is not a valid email address.","value":{"ok":false,"error":"invalid_email","message":"The 'email' field is not a valid email address.","hint":"Provide an address shaped like 'name@example.com'."}}}}}},"429":{"description":"Limite de pedidos excedido — por IP ou pelo contacto (telefone/email) submetido.","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."},"RateLimit-Limit":{"schema":{"type":"integer","minimum":1},"description":"Pedidos permitidos na janela do orçamento que decidiu esta resposta."},"RateLimit-Remaining":{"schema":{"type":"integer","minimum":0},"description":"Pedidos ainda disponíveis nessa janela, já contando este pedido."},"RateLimit-Reset":{"schema":{"type":"integer","minimum":0},"description":"Segundos até o pedido mais antigo ainda contabilizado nessa janela expirar (delta-seconds, não um timestamp) — o instante em que pelo menos mais um pedido volta a caber."},"Retry-After":{"schema":{"type":"integer","minimum":1},"description":"Segundos a esperar antes de tentar de novo."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"rate_limited":{"summary":"Too many requests.","value":{"ok":false,"error":"rate_limited","message":"Too many requests.","hint":"Wait for the duration in the Retry-After header (seconds), then retry."}}}}}},"502":{"description":"O envio do email para a receção falhou (Resend indisponível ou rejeitou a mensagem).","headers":{"API-Version":{"schema":{"type":"string","enum":["1"]},"description":"Política de versões de /api/** (ver a secção Versioning de /api/docs). Sobe só numa mudança que quebre compatibilidade — um campo novo opcional, um cabeçalho novo ou um código de erro novo (como `not_found`) nunca a sobem."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"send_failed":{"summary":"The email could not be delivered.","value":{"ok":false,"error":"send_failed","message":"The email could not be delivered.","hint":"Retry shortly, or contact the clinic by phone as a fallback."}}}}}}}}}},"components":{"schemas":{"FormToken":{"type":"object","properties":{"token":{"type":"string","description":"Token de uso único a devolver no POST."}},"required":["token"]},"ContactLead":{"type":"object","description":"Corpo do POST. Campos desconhecidos são ignorados; strings são cortadas ao limite indicado.","properties":{"name":{"type":"string","maxLength":120,"description":"Obrigatório. Nome de quem contacta."},"phone":{"type":"string","maxLength":40,"description":"Telefone em texto livre. `phone` ou `email` (ou ambos) é obrigatório."},"email":{"type":"string","maxLength":160,"pattern":"^[^\\s@]+@[^\\s@]+\\.[a-z]{2,}$","description":"`phone` ou `email` (ou ambos) é obrigatório."},"message":{"type":"string","maxLength":2000,"description":"Mensagem opcional."},"locale":{"type":"string","enum":["pt","en"],"default":"pt","description":"Idioma da confirmação enviada ao remetente."},"token":{"type":"string","description":"Token devolvido por GET /api/contacto. Em falta ou inválido: recusa silenciosa (200 delivered:false). Expirado ou já usado: 409 stale_token."},"company":{"type":"string","maxLength":80,"description":"Honeypot — deixar sempre vazio. Um valor não vazio causa recusa silenciosa (200 delivered:false)."}},"required":["name"]},"ContactResult":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"delivered":{"type":"boolean","description":"false em toda recusa deliberada e quando RESEND_API_KEY não está configurada; true assim que o email da receção foi mesmo enviado — incluindo numa repetição pelo mesmo `Idempotency-Key` de um pedido já entregue (nesse caso `confirmed` fica ausente)."},"confirmed":{"type":"boolean","description":"Presente quando delivered=true e a chamada não é uma repetição idempotente: se o remetente também recebeu a confirmação automática (não enviada sem email, ou quando o conteúdo foi marcado pela moderação). Ausente numa repetição pelo mesmo `Idempotency-Key` — nada foi reconfirmado nessa chamada."}},"required":["ok","delivered"]},"ApiError":{"type":"object","description":"Corpo devolvido em toda resposta ok:false. `error` é estável e para lógica de programa; `message`/`hint` são para quem lê a resposta (humano ou agente) perceber o que falhou e como corrigir.","properties":{"ok":{"type":"boolean","enum":[false]},"error":{"type":"string","enum":["invalid_json","forbidden","not_found","stale_token","too_large","missing_name","missing_contact","invalid_email","rate_limited","send_failed"]},"message":{"type":"string","description":"Frase curta em inglês."},"hint":{"type":"string","description":"Como resolver."}},"required":["ok","error","message","hint"]}}}}