Receita: página de agendamento externa
Monte uma página externa que lista serviços e profissionais, consulta horários e confirma agendamentos pela API da OctoSolve.
Receita: página de agendamento externa
Esta receita entrega uma página pequena e funcional com o fluxo completo:
- Lista serviços ativos que podem ser criados pela API.
- Lista profissionais vinculados ao serviço.
- Consulta a disponibilidade do dia escolhido.
- Confirma o horário com
POST /appointments. - Se receber
409 slot_conflict, atualiza os horários e pede outra escolha.
O exemplo também procura ou cria o contato pelo telefone antes da confirmação, porque todo agendamento precisa de contact_id.
A chave da API é secreta. Nunca chame a OctoSolve diretamente do JavaScript do navegador. O exemplo mantém OCTOSOLVE_API_KEY no servidor e oferece ao navegador somente rotas locais com os campos necessários.
Executar
Use Node.js 18 ou superior. Salve o conteúdo abaixo como server.mjs, defina a variável de ambiente OCTOSOLVE_API_KEY e execute node server.mjs. Depois abra http://localhost:3000.
O código inteiro está em um único bloco e usa apenas módulos nativos do Node.js:
import { createHash } from "node:crypto";
import { createServer } from "node:http";
const API_BASE = "https://api.octosolve.com.br/api/v1";
const API_KEY = process.env.OCTOSOLVE_API_KEY;
const PORT = Number(process.env.PORT || 3000);
if (!API_KEY) {
throw new Error("Defina OCTOSOLVE_API_KEY antes de iniciar o servidor.");
}
// Faz a chamada para a OctoSolve somente no servidor.
async function octo(caminho, opcoes = {}) {
const headers = {
Accept: "application/json",
Authorization: `Bearer ${API_KEY}`,
...opcoes.headers,
};
if (opcoes.body) headers["Content-Type"] = "application/json";
const resposta = await fetch(`${API_BASE}${caminho}`, { ...opcoes, headers });
const corpo = await resposta.json();
return { status: resposta.status, ok: resposta.ok, corpo };
}
// Percorre todas as páginas mantendo os mesmos filtros.
async function listarTudo(caminho, filtros) {
const itens = [];
let cursor = null;
do {
const params = new URLSearchParams({ ...filtros, limit: "100" });
if (cursor) params.set("cursor", cursor);
const resposta = await octo(`${caminho}?${params}`);
if (!resposta.ok) throw Object.assign(new Error("Falha na listagem"), resposta);
itens.push(...resposta.corpo.data);
cursor = resposta.corpo.has_more ? resposta.corpo.next_cursor : null;
} while (cursor);
return itens;
}
function hashCurto(valor) {
return createHash("sha256").update(valor).digest("hex").slice(0, 32);
}
async function lerJson(req) {
const partes = [];
let tamanho = 0;
for await (const parte of req) {
tamanho += parte.length;
if (tamanho > 64 * 1024) throw new Error("Corpo maior que 64 KB.");
partes.push(parte);
}
return JSON.parse(Buffer.concat(partes).toString("utf8"));
}
function json(res, status, corpo) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(corpo));
}
async function obterContato(nome, telefoneInformado) {
const telefone = String(telefoneInformado).replace(/\D/g, "");
if (telefone.length < 10 || telefone.length > 15) {
return { status: 422, erro: "Informe telefone com DDI e DDD." };
}
const busca = await octo(`/contacts?telefone=${encodeURIComponent(telefone)}&limit=1`);
if (!busca.ok) return { status: busca.status, corpo: busca.corpo };
if (busca.corpo.data.length) return { contato: busca.corpo.data[0] };
const criado = await octo("/contacts", {
method: "POST",
headers: { "Idempotency-Key": `site_contato_${hashCurto(telefone)}` },
body: JSON.stringify({ nome, telefone }),
});
if (criado.ok) return { contato: criado.corpo.data };
// Outra chamada pode ter criado o contato entre a busca e o POST.
const existente = criado.corpo?.error?.details?.[0]?.existing_id;
if (criado.status === 409 && existente) return { contato: { id: existente } };
return { status: criado.status, corpo: criado.corpo };
}
const pagina = `<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agende seu horário</title>
<style>
:root { font-family: system-ui, sans-serif; color: #152238; background: #f5f7f6; }
body { margin: 0; padding: 32px 16px; }
main { max-width: 680px; margin: auto; padding: 28px; background: white; border-radius: 16px; box-shadow: 0 10px 30px #0001; }
h1 { margin-top: 0; }
label { display: grid; gap: 6px; margin: 16px 0; font-weight: 600; }
input, select, button { min-height: 44px; border: 1px solid #ccd4d0; border-radius: 8px; padding: 0 12px; font: inherit; }
button { cursor: pointer; background: #087f5b; color: white; border: 0; font-weight: 700; }
button:disabled { cursor: not-allowed; opacity: .5; }
#horarios { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 8px; }
#horarios button { background: #eef8f4; color: #075b43; border: 1px solid #94d3bd; }
#horarios button[aria-pressed="true"] { background: #087f5b; color: white; }
#mensagem { min-height: 24px; margin-top: 16px; }
</style>
</head>
<body>
<main>
<h1>Agende seu horário</h1>
<form id="formulario">
<label>Nome completo <input id="nome" required maxlength="120"></label>
<label>Telefone com DDI e DDD <input id="telefone" required inputmode="tel" placeholder="5511999998888"></label>
<label>Serviço <select id="servico" required><option value="">Escolha</option></select></label>
<label>Profissional <select id="profissional" required><option value="">Escolha o serviço primeiro</option></select></label>
<label>Data <input id="data" type="date" required></label>
<p><strong>Horários livres</strong></p>
<div id="horarios">Escolha serviço, profissional e data.</div>
<input id="slot" type="hidden" required>
<button id="confirmar" type="submit" disabled>Confirmar agendamento</button>
<p id="mensagem" role="status"></p>
</form>
</main>
<script>
const servico = document.querySelector("#servico");
const profissional = document.querySelector("#profissional");
const data = document.querySelector("#data");
const horarios = document.querySelector("#horarios");
const slot = document.querySelector("#slot");
const confirmar = document.querySelector("#confirmar");
const mensagem = document.querySelector("#mensagem");
function dataLocalAmanha() {
const valor = new Date();
valor.setDate(valor.getDate() + 1);
const ano = valor.getFullYear();
const mes = String(valor.getMonth() + 1).padStart(2, "0");
const dia = String(valor.getDate()).padStart(2, "0");
return ano + "-" + mes + "-" + dia;
}
async function chamar(url, opcoes) {
const resposta = await fetch(url, opcoes);
const corpo = await resposta.json();
if (!resposta.ok) throw { status: resposta.status, corpo };
return corpo;
}
function option(select, value, texto) {
const item = document.createElement("option");
item.value = value;
item.textContent = texto;
select.appendChild(item);
}
async function carregarServicos() {
mensagem.textContent = "";
const itens = await chamar("/api/services");
for (const item of itens) option(servico, item.id, item.nome);
if (!itens.length) mensagem.textContent = "Nenhum serviço disponível para agendamento pela API.";
}
async function carregarProfissionais() {
profissional.replaceChildren();
option(profissional, "", "Escolha");
limparHorarios("Escolha um profissional.");
if (!servico.value) return;
const itens = await chamar("/api/professionals?servico_id=" + encodeURIComponent(servico.value));
for (const item of itens) option(profissional, item.id, item.nome);
if (!itens.length) mensagem.textContent = "Nenhum profissional com agenda ativa atende este serviço.";
}
function limparHorarios(texto) {
slot.value = "";
confirmar.disabled = true;
horarios.replaceChildren(document.createTextNode(texto));
}
async function carregarHorarios() {
mensagem.textContent = "";
if (!servico.value || !profissional.value || !data.value) {
limparHorarios("Escolha serviço, profissional e data.");
return;
}
limparHorarios("Consultando horários...");
const params = new URLSearchParams({
servico_id: servico.value,
profissional_id: profissional.value,
data: data.value,
dias: "1",
});
try {
const resposta = await chamar("/api/availability?" + params);
const profissionais = resposta.data.dias[0]?.profissionais || [];
const atual = profissionais.find((item) => item.profissional_id === profissional.value);
const livres = atual?.slots || [];
horarios.replaceChildren();
for (const livre of livres) {
const botao = document.createElement("button");
botao.type = "button";
botao.textContent = new Date(livre.inicio).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
botao.addEventListener("click", () => {
for (const outro of horarios.querySelectorAll("button")) outro.setAttribute("aria-pressed", "false");
botao.setAttribute("aria-pressed", "true");
slot.value = livre.inicio;
confirmar.disabled = false;
});
horarios.appendChild(botao);
}
if (!livres.length) limparHorarios("Não há horários livres nesta data.");
} catch (erro) {
limparHorarios("Não foi possível consultar os horários agora.");
mensagem.textContent = erro.corpo?.error?.message || "Tente novamente em alguns minutos.";
}
}
servico.addEventListener("change", carregarProfissionais);
profissional.addEventListener("change", carregarHorarios);
data.addEventListener("change", carregarHorarios);
document.querySelector("#formulario").addEventListener("submit", async (evento) => {
evento.preventDefault();
confirmar.disabled = true;
mensagem.textContent = "Confirmando...";
const corpo = {
nome: document.querySelector("#nome").value,
telefone: document.querySelector("#telefone").value,
servico_id: servico.value,
profissional_id: profissional.value,
data_hora: slot.value,
};
try {
const resposta = await chamar("/api/book", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corpo),
});
mensagem.textContent = "Agendamento confirmado. Código: " + resposta.data.id;
} catch (erro) {
if (erro.status === 409 && erro.corpo?.error?.code === "slot_conflict") {
mensagem.textContent = "Esse horário acabou de ser ocupado. Escolha outro.";
await carregarHorarios();
} else {
mensagem.textContent = erro.corpo?.error?.message || erro.corpo?.erro || "Não foi possível confirmar.";
}
} finally {
confirmar.disabled = !slot.value;
}
});
data.min = new Date().toLocaleDateString("en-CA");
data.value = dataLocalAmanha();
carregarServicos().catch(() => { mensagem.textContent = "Não foi possível carregar os serviços."; });
</script>
</body>
</html>`;
const servidor = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
if (req.method === "GET" && url.pathname === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end(pagina);
}
if (req.method === "GET" && url.pathname === "/api/services") {
const itens = await listarTudo("/services", { ativo: "true" });
return json(res, 200, itens.filter((item) => !item.requer_pagamento_antecipado));
}
if (req.method === "GET" && url.pathname === "/api/professionals") {
const itens = await listarTudo("/professionals", {
ativo: "true",
servico_id: url.searchParams.get("servico_id") || "",
});
return json(res, 200, itens.filter((item) => item.agenda_ativa));
}
if (req.method === "GET" && url.pathname === "/api/availability") {
const resposta = await octo(`/availability?${url.searchParams}`);
return json(res, resposta.status, resposta.corpo);
}
if (req.method === "POST" && url.pathname === "/api/book") {
const body = await lerJson(req);
const contato = await obterContato(body.nome, body.telefone);
if (!contato.contato) return json(res, contato.status || 502, contato.corpo || { erro: contato.erro });
const identidade = [contato.contato.id, body.servico_id, body.profissional_id, body.data_hora].join(":");
const resposta = await octo("/appointments", {
method: "POST",
headers: { "Idempotency-Key": `site_agendamento_${hashCurto(identidade)}` },
body: JSON.stringify({
contact_id: contato.contato.id,
servico_id: body.servico_id,
profissional_id: body.profissional_id,
data_hora: body.data_hora,
notificar_whatsapp: true,
}),
});
return json(res, resposta.status, resposta.corpo);
}
return json(res, 404, { erro: "Rota não encontrada." });
} catch (erro) {
if (erro?.corpo && erro?.status) return json(res, erro.status, erro.corpo);
console.error(erro);
return json(res, 500, { erro: "Erro interno." });
}
});
servidor.listen(PORT, () => {
console.log(`Página disponível em http://localhost:${PORT}`);
});Pontos de produção
O exemplo usa notificar_whatsapp: true, portanto cada confirmação consome a cota de mensagens. Troque para false se a sua aplicação já envia a própria confirmação.
Proteja as rotas locais com limite de requisições, validação de origem e proteção contra abuso antes de publicar. Em produção, use HTTPS e mantenha OCTOSOLVE_API_KEY no gerenciador de segredos do servidor.
O horário só está confirmado depois do 201 de /appointments. Nunca mostre sucesso com base apenas em /availability. Para serviço online, lembre também que a criação pela API devolve meet_link: null; hoje o link de reunião é gerado somente pelo agente.