API Joe v1

API para integrar o Joe com CRM, ERP, planilhas e automações: ler unidades, contatos, conversas e agenda, cadastrar contatos, enviar mensagens pelo WhatsApp e receber eventos por webhook.

  • Endereço base: https://app.seudominio.com.br/api/v1 (o endereço da sua organização, https://SUAORG.seudominio.com.br/api/v1, também funciona).
  • Tudo em JSON, com chaves em português.
  • Datas em ISO 8601. Sem fuso, vale o horário de Brasília (-03:00). Só a data (2026-10-05) também é aceita.
  • GET /api/v1 devolve o índice com os endpoints e os eventos de webhook. Com uma chave no header, também confirma se ela vale e de qual organização é.

Autenticação

Crie a chave em Configurações > API e webhooks. Ela começa com chr_live_ e aparece uma única vez: guarde num cofre de segredos.

Authorization: Bearer chr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A API e os webhooks são liberados empresa por empresa: sem a liberação, a chave responde 403 api_desligada. A chave é criada em Configurações > API e webhooks, só pela administração da empresa.

O header X-Api-Key: chr_live_... também é aceito. A chave dá acesso a todos os dados da organização: use só em servidor, nunca em navegador, app ou planilha compartilhada. Chave vazou? Revogue na tela e crie outra.

Limites

120 requisições por minuto por chave (janela fixa, vira a cada minuto cheio). Acima disso a resposta é 429 com o header Retry-After (segundos até liberar).

Erros

Todo erro vem assim:

{ "erro": { "codigo": "fora_da_janela", "mensagem": "A cliente não escreve há mais de 24 h..." } }

Erros de validação trazem também detalhes: [{ "campo": "telefone", "mensagem": "..." }].

HTTPcódigoquando
400dados_invalidosparâmetro ou campo fora do formato
400json_invalidocorpo não é JSON
400periodo_invalido, periodo_longoagenda com ate antes de de, ou mais de 92 dias
401nao_autenticado, chave_invalidasem chave, chave errada ou revogada
403api_desligadaa API não está liberada para a empresa
403unidade_bloqueadaunidade bloqueada por mensalidade
404unidade_nao_encontrada, conversa_nao_encontradanão existe nesta organização
422unidade_inativa, sem_canal_whatsappunidade desativada ou sem WhatsApp conectado
422fora_da_janelatexto livre fora das 24 h (use modelo)
422contato_descadastradoa cliente pediu para não receber mensagens
422instagram_sem_modelo, messenger_sem_modelomodelo pedido numa conversa do Instagram ou do Messenger
422modelo_nao_encontrado, modelo_nao_aprovado, modelo_nao_suportado, parametros_invalidosproblema com o modelo
422envio_recusado, whatsapp_recusouo envio foi recusado (motivo na mensagem)
429limite_excedidopassou de 120 por minuto
500erro_interno, falha_no_envioerro nosso; em falha_no_envio a mensagem pode ter saído, confira antes de repetir
502whatsapp_indisponivela Meta não respondeu bem; tente de novo

Paginação

Listas aceitam pagina (começa em 1) e limite (1 a 100, padrão 50) e respondem:

{ "dados": [ ... ], "pagina": 1, "limite": 50, "tem_mais": true, "proxima_pagina": 2 }

Continue enquanto tem_mais for true.

Endpoints

Nos exemplos, $CHAVE é a sua chave e $API é https://app.seudominio.com.br/api/v1.

GET /unidades

Unidades e seus canais. O id da unidade é o unidade_id das outras rotas.

curl -s "$API/unidades" -H "Authorization: Bearer $CHAVE"
{ "dados": [ { "id": "8f1c...", "nome": "Savassi", "endereco": "Rua ...", "ativa": true, "padrao": true,
  "criada_em": "2026-01-10T13:00:00+00:00",
  "canais": [ { "id": "c7a2...", "tipo": "whatsapp", "conectado": true, "situacao": "connected",
                "telefone": "+5531999990000", "nome": "Savassi" } ] } ] }

GET /contatos

Filtros: q (parte do nome), telefone, unidade_id. Mais novos primeiro.

curl -s "$API/contatos?q=ana&unidade_id=8f1c...&limite=100" -H "Authorization: Bearer $CHAVE"
{ "dados": [ { "id": "d4e6...", "unidade_id": "8f1c...", "nome": "Ana Souza", "telefone": "+5531984005308",
  "email": "ana@email.com", "etiquetas": ["vip"], "codigo_externo": null, "descadastrado": false,
  "criado_em": "...", "atualizado_em": "..." } ], "pagina": 1, "limite": 100, "tem_mais": false, "proxima_pagina": null }

Contatos do Instagram vêm com telefone: null.

POST /contatos

Cria ou atualiza pelo par unidade + telefone (pode mandar a base inteira de novo sem duplicar). O telefone é normalizado (DDI 55 e nono dígito). Campos que não vierem não são apagados.

curl -s -X POST "$API/contatos" -H "Authorization: Bearer $CHAVE" -H "Content-Type: application/json" \
  -d '{ "unidade_id": "8f1c...", "telefone": "31 98400-5308", "nome": "Ana Souza", "email": "ana@email.com" }'

Responde 201 com { "dados": { ...contato }, "criado": true } quando cria, ou 200 com "criado": false quando atualiza.

GET /conversas

Filtros: status (ia, fila, atendimento, resolvida; aceita lista separada por vírgula), unidade_id, desde (conversas com mensagem a partir desse instante). Da atividade mais recente para a mais antiga.

curl -s "$API/conversas?status=fila,atendimento&desde=2026-10-05T08:00:00-03:00" -H "Authorization: Bearer $CHAVE"
{ "dados": [ { "id": "eeee...", "unidade_id": "8f1c...", "canal": { "id": "c7a2...", "tipo": "whatsapp" },
  "situacao": "fila", "atendente_id": null,
  "contato": { "id": "d4e6...", "nome": "Ana Souza", "telefone": "+5531984005308" },
  "previa": "Tem horário amanhã?", "ultima_mensagem_em": "...", "ultima_mensagem_cliente_em": "...",
  "janela_24h_ate": "2026-10-06T11:30:00.000Z", "pode_enviar_texto": true, "criada_em": "..." } ],
  "pagina": 1, "limite": 50, "tem_mais": false, "proxima_pagina": null }

janela_24h_ate e pode_enviar_texto dizem até quando dá para mandar texto livre (ver POST /mensagens).

GET /conversas/{id}/mensagens

Mensagens da conversa, das mais novas para as mais antigas.

curl -s "$API/conversas/eeee.../mensagens?limite=20" -H "Authorization: Bearer $CHAVE"
{ "dados": [ { "id": "5fa4...", "direcao": "recebida", "de": "cliente", "tipo": "audio", "texto": null,
  "tem_midia": true, "transcricao": "Oi, tem horário amanhã?", "situacao": "lida", "criada_em": "..." } ],
  "pagina": 1, "limite": 20, "tem_mais": true, "proxima_pagina": 2 }

direcao: recebida ou enviada. de: cliente, ia, equipe ou sistema. situacao: na_fila, enviada, entregue, lida, falhou. O arquivo da mídia não sai pela API.

POST /mensagens

Destino: conversa_id, ou unidade_id + telefone (usa o WhatsApp conectado da unidade e a conversa que a cliente já tiver). Conteúdo: texto, ou modelo.

Regras do WhatsApp:

  • Texto livre só dentro de 24 h da última mensagem da cliente. Fora disso a resposta é 422 fora_da_janela: use um modelo.
  • Modelo (template aprovado na Meta) vai a qualquer hora e abre a conversa se ela não existir. Os parametros preenchem {{1}}, {{2}}... do corpo, na ordem, e não podem ser vazios. Modelo com mídia no cabeçalho, variável no cabeçalho ou botão de link dinâmico ainda não é suportado.
  • Instagram: só texto, só dentro de 24 h. Não existe modelo.
  • Cliente descadastrada (descadastrado: true) não recebe nada: 422 contato_descadastrado.

A mensagem entra no histórico como sistema. A conversa não muda de situação: quem estava atendendo (IA ou equipe) continua.

Texto numa conversa aberta:

curl -s -X POST "$API/mensagens" -H "Authorization: Bearer $CHAVE" -H "Content-Type: application/json" \
  -d '{ "conversa_id": "eeee...", "texto": "Seu pedido saiu para entrega!" }'

Modelo para um telefone (cria contato e conversa se precisar; nome só é usado para contato novo):

curl -s -X POST "$API/mensagens" -H "Authorization: Bearer $CHAVE" -H "Content-Type: application/json" \
  -d '{ "unidade_id": "8f1c...", "telefone": "5531984005308", "nome": "Ana Souza",
        "modelo": { "nome": "lembrete_horario", "idioma": "pt_BR", "parametros": ["Ana", "amanhã às 15h"] } }'

Resposta 201:

{ "dados": { "conversa_id": "eeee...", "mensagem_id": "9b1f...", "tipo": "modelo",
             "texto": "Olá Ana, seu horário é amanhã às 15h." } }

Não repita automaticamente um envio que deu 500 falha_no_envio: a mensagem pode ter saído. Confira a conversa antes.

GET /agendamentos

de e ate são obrigatórios, com no máximo 92 dias entre eles. ate só com a data inclui o dia inteiro. Filtro: unidade_id. Em ordem de horário.

curl -s "$API/agendamentos?de=2026-10-01&ate=2026-10-31&unidade_id=8f1c..." -H "Authorization: Bearer $CHAVE"
{ "dados": [ { "id": "a1b2...", "unidade_id": "8f1c...", "contato_id": "d4e6...", "conversa_id": null,
  "cliente": "Ana Souza", "inicio": "2026-10-05T13:00:00+00:00", "fim": "2026-10-05T14:30:00.000Z",
  "duracao_min": 90, "situacao": "confirmado",
  "servico": { "id": "s1...", "nome": "Corte" }, "profissional": { "id": "p1...", "nome": "Bia" },
  "servicos": [ { "id": "s1...", "nome": "Corte", "profissional_id": "p1...", "duracao_min": 60 },
                { "id": "s2...", "nome": "Escova", "profissional_id": "p1...", "duracao_min": 30 } ],
  "criado_em": "..." } ], "pagina": 1, "limite": 50, "tem_mais": false, "proxima_pagina": null }

situacao: agendado, confirmado, concluido, faltou, cancelado.

Imóveis e veículos

Para quem já tem os imóveis ou os carros num sistema próprio: o seu sistema manda os itens pelo código dele, e o Joe mantém igual. Mandar o mesmo código de novo atualiza, nunca duplica. Precisa da funcionalidade "Imóveis" ou "Estoque de veículos" liberada.

Imobiliária com sistema que gera o XML dos portais (Zap, VivaReal, OLX) não precisa da API: basta colar o link do XML em Imóveis, no painel, e os imóveis se atualizam sozinhos de hora em hora.

Os caminhos são /imoveis e /veiculos, com as mesmas regras.

POST /imoveis e POST /veiculos

Até 200 itens por requisição. Campo que não vier fica como está; ficha e fotos, quando vêm, substituem as anteriores.

CampoDescrição
codigoObrigatório. O código do item no seu sistema (até 60 caracteres).
tituloSem título, o Joe monta um pela ficha ("Apartamento com 3 quartos, Buritis").
descricaoTexto livre, até 4.000 caracteres.
precoVenda, em reais (número). null = sob consulta.
preco_aluguelSó imóvel: aluguel por mês (ou diária, na finalidade Temporada).
situacaodisponivel, reservado, vendido, alugado (só imóvel) ou pausado. Sem ela, item novo entra disponível e item existente mantém a situação.
fotosAté 20 links públicos (https://...), na ordem; a primeira é a capa.
fichaObjeto com os campos do tipo (abaixo). Campo desconhecido ou em formato errado é ignorado.

Ficha do imóvel: finalidade (Venda, Aluguel, Venda e aluguel, Temporada), tipo_imovel (Apartamento, Casa, Casa em condomínio, Cobertura, Flat ou studio, Sala comercial, Loja, Galpão, Lote ou terreno, Sítio ou fazenda, Outro), bairro, cidade, endereco, area_util, area_total, quartos, suites, banheiros, vagas, andar, condominio (por mês), iptu (por ano), mobiliado (Sem mobília, Semimobiliado, Mobiliado), aceita_pets, aceita_financiamento, aceita_permuta (true/false), caracteristicas (lista), tour_url, proprietario.

Ficha do veículo: marca, modelo, versao, ano_fabricacao, ano_modelo, km, cambio (Manual, Automático, CVT, Automatizado), combustivel (Flex, Gasolina, Etanol, Diesel, Híbrido, Elétrico, GNV), cor, portas, opcionais (lista), aceita_troca, unico_dono (true/false), placa, fipe_codigo, fipe_preco, fipe_referencia.

endereco, proprietario, placa e os campos da FIPE ficam só para a equipe: não vão para a cliente nem para a IA.

curl -s -X POST "$API/imoveis" -H "Authorization: Bearer $CHAVE" -H "Content-Type: application/json" \
  -d '{ "unidade_id": "8f1c...", "itens": [ {
        "codigo": "AP-0318", "preco": 850000, "preco_aluguel": null,
        "fotos": ["https://seusistema.com.br/fotos/ap0318-1.jpg", "https://seusistema.com.br/fotos/ap0318-2.jpg"],
        "ficha": { "finalidade": "Venda", "tipo_imovel": "Apartamento", "bairro": "Buritis", "cidade": "Belo Horizonte",
                   "quartos": 3, "suites": 1, "vagas": 2, "area_util": 95, "condominio": 650 } } ] }'

Resposta 201 (algum item novo) ou 200:

{ "dados": { "criados": 1, "atualizados": 0, "sem_mudanca": 0,
             "itens": [ { "codigo": "AP-0318", "id": "c0de..." } ] } }

GET /imoveis e GET /veiculos

Filtros: unidade_id, situacao, codigo. Paginado. Cada item traz id, unidade_id, codigo, origem (manual, feed ou api), titulo, descricao, preco, preco_aluguel (imóvel), situacao, fotos, ficha, criado_em e atualizado_em.

GET e DELETE /imoveis/{codigo} e /veiculos/{codigo}

Com ?unidade_id= na query. O DELETE tira o item do ar (situação pausado), sem apagar: conversas que falaram dele continuam ligadas. Mandar o código de novo pelo POST traz de volta.

Sócios

Para clubes e programas de associados (funcionalidade "Programa de sócios"). O sócio é da organização inteira e é ligado à conversa pelo telefone: a equipe vê a situação no topo da conversa e a assistente responde sobre plano e vencimento.

POST /socios

Cria ou atualiza até 500 por chamada. Com numero, a chave é o número do sócio; sem número, o telefone. Só os campos enviados mudam: quando a mensalidade atrasa, mande só { "numero": "12345", "situacao": "inadimplente" }.

{
  "socios": [
    { "numero": "12345", "nome": "Ana Souza", "telefone": "31999998888", "plano": "Ouro", "situacao": "ativo", "vencimento": "2026-12-31", "adesao": "2024-03-01" }
  ]
}

situacao: ativo, inadimplente, pausado ou cancelado. Datas em AAAA-MM-DD. Resposta: { "criados": 1, "atualizados": 0, "ignorados": 0 }.

GET /socios

Filtros: numero, telefone, situacao, plano. Paginado.

Webhooks

Cadastre uma URL https:// em Configurações > API e webhooks e escolha os eventos (ou todos). A cada evento o Joe faz um POST com JSON. Endereço interno (localhost, IP de rede privada) é recusado.

eventoquando
mensagem.recebidaa cliente mandou mensagem
mensagem.enviadasaiu mensagem (IA, equipe, sistema ou API)
conversa.na_filaa conversa entrou na fila da recepção
conversa.resolvidaa conversa foi resolvida
agendamento.criadoagendamento novo
agendamento.atualizadomudou situação, horário ou profissional
contato.criadocontato novo
cobranca.pagaPix de sinal pago
avaliacao.recebidaa cliente respondeu a pesquisa de satisfação

O botão "Testar" da tela manda um evento teste.ping.

Headers:

Content-Type: application/json
User-Agent: Joe-Webhooks/1.0
X-Joe-Evento: mensagem.recebida
X-Joe-Entrega: 18342
X-Joe-Assinatura: t=1791207011,v1=5f1c0e...

Corpo (exemplo de mensagem.recebida):

{
  "id": "e3880e0d-88a4-4373-bf10-428b1abcc730",
  "evento": "mensagem.recebida",
  "criado_em": "2026-10-05T11:30:11.880Z",
  "dados": {
    "mensagem_id": "5fa44bcf-f541-44e8-bf7c-b9598f682995",
    "conversa_id": "eeeeeeee-0000-0000-0000-000000000000",
    "unidade_id": "bbbbbbbb-0000-0000-0000-000000000000",
    "contato": { "id": "dddddddd-...", "nome": "Ana Souza", "telefone": "+5531984005308" },
    "de": "contact",
    "tipo": "text",
    "texto": "Oi, tem horário amanhã?",
    "criada_em": "2026-10-05T08:30:11.880113-03:00"
  }
}

Campos de dados por evento:

  • mensagem.*: mensagem_id, conversa_id, unidade_id, contato { id, nome, telefone }, de, tipo, texto, criada_em.
  • conversa.na_fila, conversa.resolvida: conversa_id, unidade_id, contato_id, motivo, atendente_id.
  • agendamento.*: agendamento_id, unidade_id, contato_id, conversa_id, cliente, servico_id, profissional_id, inicio, duracao_min, situacao.
  • contato.criado: contato_id, unidade_id, nome, telefone.
  • cobranca.paga: cobranca_id, unidade_id, contato_id, conversa_id, agendamento_id, valor, descricao, pago_em.
  • avaliacao.recebida: conversa_id, unidade_id, atendente_id, nota (1 a 5), respondida_em.

Atenção: nos webhooks alguns valores vêm com o nome interno, diferente da API:

campowebhookAPI
de (mensagem)contact, ai, human, systemcliente, ia, equipe, sistema
situacao (agendamento)scheduled, confirmed, done, no_show, cancelledagendado, confirmado, concluido, faltou, cancelado

Como responder

  • Responda 2xx em até 8 segundos. Se o processamento for demorado, guarde o evento numa fila e responda logo.
  • Redirecionamento (301, 302...) não é seguido e conta como falha: cadastre a URL final.
  • O mesmo evento pode chegar mais de uma vez (por exemplo, se sua resposta atrasou) e fora de ordem. Use o id do corpo para não processar duas vezes e criado_em para ordenar.

Validar a assinatura

X-Joe-Assinatura traz t (horário Unix do envio) e v1 (HMAC-SHA256 em hex de "<t>.<corpo cru>", com o segredo do endpoint como chave, inteiro, com o whsec_). Valide sempre:

  1. Use o corpo cru, exatamente como chegou, antes de converter o JSON.
  2. Calcule o HMAC e compare com v1 em tempo constante.
  3. Recuse se t estiver a mais de 5 minutos do seu relógio (evita reenvio de requisição capturada).

Node (Express):

import crypto from "node:crypto";
import express from "express";

const SEGREDO = process.env.JOE_WEBHOOK_SECRET; // whsec_...

function assinaturaValida(corpoCru, header, segredo, toleranciaSeg = 300) {
  const partes = Object.fromEntries(
    String(header || "").split(",").map((p) => p.trim().split("=", 2)),
  );
  const t = Number(partes.t);
  if (!Number.isInteger(t) || !partes.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return false;
  const esperado = crypto.createHmac("sha256", segredo).update(`${t}.${corpoCru}`).digest();
  const recebido = Buffer.from(partes.v1, "hex");
  return recebido.length === esperado.length && crypto.timingSafeEqual(recebido, esperado);
}

const app = express();
// express.raw: o corpo chega como Buffer, sem passar pelo parser de JSON.
app.post("/webhooks/joe", express.raw({ type: "application/json" }), (req, res) => {
  const corpoCru = req.body.toString("utf8");
  if (!assinaturaValida(corpoCru, req.get("X-Joe-Assinatura"), SEGREDO)) {
    return res.status(401).end();
  }
  const evento = JSON.parse(corpoCru);
  // Guarde evento.id para ignorar repetição; processe em fila.
  res.status(200).end();
});

app.listen(3000);

PHP:

<?php
$segredo  = getenv('JOE_WEBHOOK_SECRET'); // whsec_...
$corpoCru = file_get_contents('php://input');
$header   = $_SERVER['HTTP_X_JOE_ASSINATURA'] ?? '';

$partes = [];
foreach (explode(',', $header) as $parte) {
    [$chave, $valor] = array_pad(explode('=', trim($parte), 2), 2, '');
    $partes[$chave] = $valor;
}
$t  = isset($partes['t']) ? (int) $partes['t'] : 0;
$v1 = $partes['v1'] ?? '';

$esperado = hash_hmac('sha256', $t . '.' . $corpoCru, $segredo);
if ($t === 0 || abs(time() - $t) > 300 || !hash_equals($esperado, $v1)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($corpoCru, true);
// Guarde $evento['id'] para ignorar repetição; processe em fila.
http_response_code(200);

Novas tentativas

Qualquer resposta fora de 2xx, erro de conexão ou demora acima de 8 s é falha. O Joe tenta de novo depois de:

falhapróxima tentativa
1ª1 min
2ª5 min
3ª15 min
4ª1 h
5ª3 h
6ª6 h
7ª12 h
8ªdesiste

São 8 tentativas em cerca de 22 horas. Enquanto uma URL está falhando, ela recebe uma entrega de cada vez até voltar a responder, para não ser sobrecarregada quando se recuperar.

Depois de 100 falhas seguidas o endpoint é desativado e as entregas pendentes dele são descartadas. Corrija a URL e reative em Configurações > API e webhooks; o histórico das entregas fica disponível por 30 dias.