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/v1devolve 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": "..." }].
| HTTP | código | quando |
|---|---|---|
| 400 | dados_invalidos | parâmetro ou campo fora do formato |
| 400 | json_invalido | corpo não é JSON |
| 400 | periodo_invalido, periodo_longo | agenda com ate antes de de, ou mais de 92 dias |
| 401 | nao_autenticado, chave_invalida | sem chave, chave errada ou revogada |
| 403 | api_desligada | a API não está liberada para a empresa |
| 403 | unidade_bloqueada | unidade bloqueada por mensalidade |
| 404 | unidade_nao_encontrada, conversa_nao_encontrada | não existe nesta organização |
| 422 | unidade_inativa, sem_canal_whatsapp | unidade desativada ou sem WhatsApp conectado |
| 422 | fora_da_janela | texto livre fora das 24 h (use modelo) |
| 422 | contato_descadastrado | a cliente pediu para não receber mensagens |
| 422 | instagram_sem_modelo, messenger_sem_modelo | modelo pedido numa conversa do Instagram ou do Messenger |
| 422 | modelo_nao_encontrado, modelo_nao_aprovado, modelo_nao_suportado, parametros_invalidos | problema com o modelo |
| 422 | envio_recusado, whatsapp_recusou | o envio foi recusado (motivo na mensagem) |
| 429 | limite_excedido | passou de 120 por minuto |
| 500 | erro_interno, falha_no_envio | erro nosso; em falha_no_envio a mensagem pode ter saído, confira antes de repetir |
| 502 | whatsapp_indisponivel | a 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
parametrospreenchem{{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.
| Campo | Descrição |
|---|---|
codigo | Obrigatório. O código do item no seu sistema (até 60 caracteres). |
titulo | Sem título, o Joe monta um pela ficha ("Apartamento com 3 quartos, Buritis"). |
descricao | Texto livre, até 4.000 caracteres. |
preco | Venda, em reais (número). null = sob consulta. |
preco_aluguel | Só imóvel: aluguel por mês (ou diária, na finalidade Temporada). |
situacao | disponivel, reservado, vendido, alugado (só imóvel) ou pausado. Sem ela, item novo entra disponível e item existente mantém a situação. |
fotos | Até 20 links públicos (https://...), na ordem; a primeira é a capa. |
ficha | Objeto 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.
| evento | quando |
|---|---|
mensagem.recebida | a cliente mandou mensagem |
mensagem.enviada | saiu mensagem (IA, equipe, sistema ou API) |
conversa.na_fila | a conversa entrou na fila da recepção |
conversa.resolvida | a conversa foi resolvida |
agendamento.criado | agendamento novo |
agendamento.atualizado | mudou situação, horário ou profissional |
contato.criado | contato novo |
cobranca.paga | Pix de sinal pago |
avaliacao.recebida | a 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:
| campo | webhook | API |
|---|---|---|
de (mensagem) | contact, ai, human, system | cliente, ia, equipe, sistema |
situacao (agendamento) | scheduled, confirmed, done, no_show, cancelled | agendado, confirmado, concluido, faltou, cancelado |
Como responder
- Responda
2xxem 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
iddo corpo para não processar duas vezes ecriado_empara 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:
- Use o corpo cru, exatamente como chegou, antes de converter o JSON.
- Calcule o HMAC e compare com
v1em tempo constante. - Recuse se
testiver 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:
| falha | pró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.