OctoSolveAjuda

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:

  1. Lista serviços ativos que podem ser criados pela API.
  2. Lista profissionais vinculados ao serviço.
  3. Consulta a disponibilidade do dia escolhido.
  4. Confirma o horário com POST /appointments.
  5. 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:

server.mjs
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.

On this page