Capítulo 11 de 15
O código completo do agente de IA no WhatsApp
O que a Certu mediu no WhatsApp de 67 negócios
Nos 67 negócios que atendem com a IA da Certu, 32,5% das 122.720 mensagens de cliente chegaram fora de segunda a sexta, das 8h às 18h (30 de julho a 30 de setembro de 2026). Metade das respostas da IA saiu em até 11,7 segundos, e 9 em cada 10 em até 20,7 segundos (38.908 respostas). 34,6% das conversas começaram fora do horário comercial, e a hora com mais mensagens foi 11h.
| O que foi medido | Resultado | Base |
|---|---|---|
| Mensagens de cliente fora de segunda a sexta, 8h às 18h | 32,5% | 122.720 mensagens de cliente de 67 negócios |
| Mensagens no fim de semana | 15,8% | 122.720 mensagens de cliente de 67 negócios |
| Conversas que começam fora do horário comercial | 34,6% | 19.900 conversas |
| Tempo até a IA responder (metade das respostas) | até 11,7 segundos | 38.908 respostas da IA |
| Tempo até a IA responder (9 em cada 10 respostas) | até 20,7 segundos | 38.908 respostas da IA |
| Hora do dia com mais mensagens de cliente | 11h | 122.720 mensagens de cliente de 67 negócios |
Fonte: base de atendimento da Certu, só números somados, nenhuma conversa lida por gente; sem histórico importado, sem contato de teste e sem o atendimento de vendas da própria Certu. 30 de julho a 30 de setembro de 2026, medido em 09/10/2026. Método e tabela por ramo no Índice Certu de Atendimento por WhatsApp e em certu.com.br/dados.
Clientes com nome, 30/07 a 10/10/2026
| Cliente | Mensagens de cliente | Respostas da IA | Conversas atendidas | Fora do horário | IA responde em (mediana) | Dias com mensagem |
|---|---|---|---|---|---|---|
| JP Gás Água e Rações distribuidora de gás e água | 42.832 | 23.351 | 6.479 | 33,6% | 9,8 s | 70 |
| CEMIP clínica médica | 12.472 | 6.484 | 2.436 | 18,7% | 11,4 s | 37 |
| Nikiti Auto Center auto center | 4.912 | 3.548 | 900 | 19,5% | 14,4 s | 47 |
| Imobiliária Rodrigues & Ribeiro imobiliária | 6.090 | 2.759 | 823 | 33,2% | 18,4 s | 34 |
| Alpha RH recursos humanos | 3.082 | 1.666 | 721 | 27,5% | 15,6 s | 52 |
Números de operação do painel de cada conta, mesma régua do Índice, medidos em 11/10/2026. Publicados com a autorização de cada dono. Resultado de venda só sai com o dono confirmando por escrito. Lista completa em certu.com.br/casos.
O que clientes da Certu escreveram
A Certu reúne em um só lugar recursos que normalmente exigiriam várias ferramentas. Atendimento pelo WhatsApp, CRM, agenda, acompanhamento de oportunidades, campanhas e gestão da operação ficam conectados. No geral, a proposta é muito boa para quem quer reduzir tarefas manuais e ter mais controle sobre o que acontece no atendimento e nas vendas. [...]
Avaliação pública (com o link) ou texto enviado pela própria empresa e publicado com autorização por escrito. A nota só aparece quando a fonte tem nota.
Sobre a Certu: a Certu é a plataforma brasileira de IA que atende o WhatsApp, o Instagram e o Messenger de pequenas e médias empresas 24 horas por dia, com CRM incluído, e é Tech Provider da Plataforma WhatsApp Business da Meta. Os planos começam em 12x de R$ 247 no anual, sem fidelidade. Conectar meu WhatsApp
Todo o código num arquivo, pronto para publicar.
- Leitura: 2 min
- Custo para acompanhar: R$ 0
- Cadastro: nenhum
Manual gratuito de Certu · Atualizado em 23 de setembro de 2026 · Escrito por Bernardo Villela, CEO de Certu · ver o manual inteiro
Resumo em 3 fatos
- Resposta curta Um arquivo único que recebe a mensagem, confere a assinatura, entende áudio e foto, lembra da conversa, trava valor inventado, chama uma pessoa e responde.
- Os quatro segredos entram com npx wrangler secret put: VERIFY_TOKEN, APP_SECRET, WHATSAPP_TOKEN (a chave permanente do capítulo 3) e GEMINI_API_KEY (aistudio.google.com).
- Depois, npx wrangler deploy. wrangler.jsoncjsonc { "name": "atendente", "main": "src/index.js", "compatibility_date": "2026-08-01", // Crie com: npx wrangler kv namespace create CONVERSAS // O comando imprime o id.
Em números
| Número | O que significa |
|---|---|
| 48 horas | Encomenda de bolo: com 48 horas de antecedencia. |
| 3 linhas | Portugues do Brasil, no maximo 3 linhas por mensagem, uma pergunta por vez. |
Como juntar todo o código do manual em um arquivo só?
Resposta curta
Um arquivo único que recebe a mensagem, confere a assinatura, entende áudio e foto, lembra da conversa, trava valor inventado, chama uma pessoa e responde. Copie, publique e funciona.
O arquivo
Cole em src/index.js. Você só edita o CEREBRO e a lista de valores.
// ─────────────────────────────────────────────────────────────────────
// O que voce edita: o CEREBRO e a lista de precos permitidos.
// O resto funciona igual para qualquer negocio.
// ─────────────────────────────────────────────────────────────────────
const CEREBRO = `Voce e o atendente da Padaria do Ze, em Florianopolis.
Horario: segunda a sabado, das 6h as 20h. Domingo ate as 12h.
Bolo de fuba: R$ 80. Torta salgada: R$ 120. Bolo de festa: R$ 250.
Encomenda de bolo: com 48 horas de antecedencia.
Portugues do Brasil, no maximo 3 linhas por mensagem, uma pergunta por vez.
Nunca invente preco, prazo ou horario. Nunca prometa desconto.
Se nao souber, diga "Vou confirmar isso e ja te respondo." e escreva
<<CHAMAR HUMANO>> no fim da resposta.`;
const PRECOS_PERMITIDOS = ['80', '120', '250'];
const MARCA = '<<CHAMAR HUMANO>>';
const MAX_TROCAS = 10; // 10 do cliente + 10 do atendente
const VALIDADE = 60 * 60 * 24; // historico: 24 horas
const SILENCIO = 60 * 60 * 2; // robo calado: 2 horas
const JA_VISTA = 60 * 60 * 6; // id de mensagem ja tratada: 6 horas
const RECADO_DE_ESPERA = 'Vou confirmar isso e ja te respondo.';
// ─────────────────────────────────────────────────────────────────────
// 1. A porta de entrada
// ─────────────────────────────────────────────────────────────────────
export default {
async fetch(request, env, context) {
const url = new URL(request.url);
if (url.pathname !== '/webhook') {
return new Response('no ar', { status: 200 });
}
// Verificacao. A Meta chama uma vez, quando voce cadastra o endereco.
if (request.method === 'GET') {
const modo = url.searchParams.get('hub.mode');
const token = url.searchParams.get('hub.verify_token');
const desafio = url.searchParams.get('hub.challenge');
if (modo === 'subscribe' && token === env.VERIFY_TOKEN) {
return new Response(desafio, { status: 200 });
}
return new Response('token errado', { status: 403 });
}
if (request.method !== 'POST') {
return new Response('metodo nao permitido', { status: 405 });
}
const corpo = await request.text();
const assinatura = request.headers.get('x-hub-signature-256');
if (!(await assinaturaConfere(corpo, assinatura, env.APP_SECRET))) {
return new Response('assinatura invalida', { status: 401 });
}
const dados = JSON.parse(corpo);
const valor = dados?.entry?.[0]?.changes?.[0]?.value;
const mensagem = valor?.messages?.[0];
// Confirmacao de entrega e de leitura chegam no mesmo endereco.
if (!mensagem) return new Response('ok', { status: 200 });
const de = mensagem.from;
// A Meta reenvia quando voce demora. Trate cada id uma vez so.
if (await jaTratada(env, mensagem.id)) {
return new Response('ok', { status: 200 });
}
// Uma pessoa assumiu esta conversa: o robo nao fala por cima.
if (await estaEmSilencio(env, de)) {
return new Response('ok', { status: 200 });
}
// Responde 200 primeiro, trabalha depois.
context.waitUntil(atender(env, de, mensagem));
return new Response('ok', { status: 200 });
},
};
// ─────────────────────────────────────────────────────────────────────
// 2. O atendimento
// ─────────────────────────────────────────────────────────────────────
async function atender(env, de, mensagem) {
await marcarComoLida(env, mensagem.id);
const texto = await extrairTexto(env, mensagem);
if (!texto) {
await enviarWhatsApp(env, de, 'Recebi aqui. Me conta em uma frase o que voce precisa?');
return;
}
const historico = await lerHistorico(env, de);
const resposta = await pensarResposta(env, CEREBRO, historico, texto);
// Modelo fora do ar. Nao invente uma resposta generica.
if (!resposta) return await chamarHumano(env, de, texto);
// O proprio modelo pediu ajuda.
if (resposta.includes(MARCA)) {
const limpa = resposta.replace(MARCA, '').trim();
if (limpa) await enviarWhatsApp(env, de, limpa);
return await chamarHumano(env, de, texto, false);
}
// Preco fora da tabela ou desconto prometido nao chega no cliente.
if (!respostaSegura(resposta)) return await chamarHumano(env, de, texto);
await enviarWhatsApp(env, de, resposta);
historico.push({ papel: 'user', texto });
historico.push({ papel: 'model', texto: resposta });
await salvarHistorico(env, de, historico);
}
async function chamarHumano(env, de, ultimaMensagem, avisarCliente = true) {
if (avisarCliente) await enviarWhatsApp(env, de, RECADO_DE_ESPERA);
await calarRobo(env, de);
await avisarDono(env, de, ultimaMensagem);
}
// ─────────────────────────────────────────────────────────────────────
// 3. Texto, audio e foto viram a mesma coisa: uma frase
// ─────────────────────────────────────────────────────────────────────
async function extrairTexto(env, mensagem) {
if (mensagem.type === 'text') return mensagem.text.body;
if (mensagem.type === 'audio') {
const midia = await baixarMidia(env, mensagem.audio.id);
if (!midia) return '';
return (await lerMidia(env, midia, 'Transcreva este audio em portugues do Brasil.')) ?? '';
}
if (mensagem.type === 'image') {
const midia = await baixarMidia(env, mensagem.image.id);
if (!midia) return '';
const visto = await lerMidia(env, midia, 'Descreva o que aparece nesta imagem em uma frase.');
const legenda = mensagem.image.caption ? ' O cliente escreveu: ' + mensagem.image.caption : '';
return visto ? '[o cliente mandou uma foto de: ' + visto + ']' + legenda : '';
}
return '';
}
async function baixarMidia(env, idDaMidia) {
const info = await fetch('https://graph.facebook.com/v26.0/' + idDaMidia, {
headers: { Authorization: 'Bearer ' + env.WHATSAPP_TOKEN },
});
if (!info.ok) return null;
const { url, mime_type } = await info.json();
const arquivo = await fetch(url, {
headers: { Authorization: 'Bearer ' + env.WHATSAPP_TOKEN },
});
if (!arquivo.ok) return null;
const bytes = new Uint8Array(await arquivo.arrayBuffer());
let binario = '';
for (const b of bytes) binario += String.fromCharCode(b);
return { base64: btoa(binario), mime: mime_type };
}
async function lerMidia(env, midia, instrucao) {
const resposta = await chamarModelo(env, {
contents: [{
role: 'user',
parts: [
{ text: instrucao },
{ inline_data: { mime_type: midia.mime, data: midia.base64 } },
],
}],
});
return resposta;
}
// ─────────────────────────────────────────────────────────────────────
// 4. O modelo
// ─────────────────────────────────────────────────────────────────────
async function chamarModelo(env, corpo) {
const resposta = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent',
{
method: 'POST',
headers: {
'x-goog-api-key': env.GEMINI_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(corpo),
},
);
if (!resposta.ok) {
console.log('modelo falhou', resposta.status, await resposta.text());
return null;
}
const dados = await resposta.json();
return dados?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? null;
}
async function pensarResposta(env, cerebro, historico, mensagemDoCliente) {
return await chamarModelo(env, {
system_instruction: { parts: [{ text: cerebro }] },
contents: [
...historico.map((m) => ({ role: m.papel, parts: [{ text: m.texto }] })),
{ role: 'user', parts: [{ text: mensagemDoCliente }] },
],
generationConfig: { temperature: 0.4, maxOutputTokens: 300 },
});
}
// ─────────────────────────────────────────────────────────────────────
// 5. A trava que nao deixa preco inventado sair
// ─────────────────────────────────────────────────────────────────────
function respostaSegura(texto) {
const valores = texto.match(/R\$\s?([\d.]+)/g) ?? [];
for (const bruto of valores) {
const numero = bruto.replace(/[^\d]/g, '');
if (!PRECOS_PERMITIDOS.includes(numero)) return false;
}
if (/desconto|abatimento|fa[cç]o por|deixo por/i.test(texto)) return false;
return true;
}
// ─────────────────────────────────────────────────────────────────────
// 6. Memoria, silencio e mensagem repetida
// ─────────────────────────────────────────────────────────────────────
async function lerHistorico(env, telefone) {
const bruto = await env.CONVERSAS.get('hist:' + telefone);
return bruto ? JSON.parse(bruto) : [];
}
async function salvarHistorico(env, telefone, historico) {
const cortado = historico.slice(-MAX_TROCAS * 2);
await env.CONVERSAS.put('hist:' + telefone, JSON.stringify(cortado), {
expirationTtl: VALIDADE,
});
}
async function estaEmSilencio(env, telefone) {
return (await env.CONVERSAS.get('humano:' + telefone)) !== null;
}
async function calarRobo(env, telefone) {
await env.CONVERSAS.put('humano:' + telefone, '1', { expirationTtl: SILENCIO });
}
async function jaTratada(env, idDaMensagem) {
const chave = 'msg:' + idDaMensagem;
if (await env.CONVERSAS.get(chave)) return true;
await env.CONVERSAS.put(chave, '1', { expirationTtl: JA_VISTA });
return false;
}
// ─────────────────────────────────────────────────────────────────────
// 7. Falar com o WhatsApp
// ─────────────────────────────────────────────────────────────────────
async function enviarWhatsApp(env, para, texto) {
const resposta = await fetch(
'https://graph.facebook.com/v26.0/' + env.PHONE_NUMBER_ID + '/messages',
{
method: 'POST',
headers: {
Authorization: 'Bearer ' + env.WHATSAPP_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to: para,
type: 'text',
text: { body: texto },
}),
},
);
if (!resposta.ok) {
console.log('envio falhou', resposta.status, await resposta.text());
}
}
async function marcarComoLida(env, idDaMensagem) {
await fetch('https://graph.facebook.com/v26.0/' + env.PHONE_NUMBER_ID + '/messages', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + env.WHATSAPP_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
status: 'read',
message_id: idDaMensagem,
}),
}).catch(() => {});
}
// O dono so recebe este aviso se ELE tiver escrito para o numero da empresa
// nas ultimas 24 horas. Fora da janela, este envio falha calado. Leia o
// aviso logo abaixo do arquivo: e o erro mais chato deste capitulo.
async function avisarDono(env, telefone, ultimaMensagem) {
await enviarWhatsApp(
env,
env.TELEFONE_DO_DONO,
'Cliente ' + telefone + ' precisa de voce.\n\nUltima mensagem: ' + ultimaMensagem,
);
}
// ─────────────────────────────────────────────────────────────────────
// 8. A assinatura da Meta
// ─────────────────────────────────────────────────────────────────────
async function assinaturaConfere(corpo, cabecalho, segredo) {
if (!cabecalho || !segredo) return false;
const chave = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(segredo),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const assinado = await crypto.subtle.sign('HMAC', chave, new TextEncoder().encode(corpo));
const hex = [...new Uint8Array(assinado)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const esperado = 'sha256=' + hex;
// Comparacao de tempo constante.
if (esperado.length !== cabecalho.length) return false;
let diferenca = 0;
for (let i = 0; i < esperado.length; i += 1) {
diferenca |= esperado.charCodeAt(i) ^ cabecalho.charCodeAt(i);
}
return diferenca === 0;
}Onde as pessoas se queimam
O aviso ao dono só chega se ele escreveu para o número nas últimas 24 horas.
Configurar e publicar
Os quatro segredos entram com npx wrangler secret put: VERIFY_TOKEN, APP_SECRET, WHATSAPP_TOKEN (a chave permanente do capítulo 3) e GEMINI_API_KEY (aistudio.google.com). Depois, npx wrangler deploy.
{
"name": "atendente",
"main": "src/index.js",
"compatibility_date": "2026-08-01",
// Crie com: npx wrangler kv namespace create CONVERSAS
// O comando imprime o id. Cole ele aqui.
"kv_namespaces": [
{ "binding": "CONVERSAS", "id": "COLE_O_ID_QUE_O_COMANDO_IMPRIMIU" }
],
// Isto nao e segredo: pode ficar no arquivo e no repositorio.
"vars": {
"PHONE_NUMBER_ID": "106540352242922",
"TELEFONE_DO_DONO": "5548999999999"
}
}Pergunta frequente
Roda em Node?
Roda. CONVERSAS vira Redis e waitUntil vira responder 200 e seguir.
E onde entra Certu
Falta só o texto do capítulo 6, e ele sai de graça numa conversa em Certu.
Montar o texto do meu negócio de graçaContinuar o manual
Em resumo
O arquivo único com todo o código do manual junto: webhook, assinatura, IA, memória, áudio, foto, trava de preço e saída para o humano.
Sobre a Certu: a Certu é a plataforma brasileira de IA que atende o WhatsApp, o Instagram e o Messenger de pequenas e médias empresas 24 horas por dia, com CRM incluído, e é Tech Provider da Plataforma WhatsApp Business da Meta. Os planos começam em 12x de R$ 247 no anual, sem fidelidade. Conectar meu WhatsApp
