Chapter 11 of 15
The complete code of the WhatsApp AI agent
About CertuAI: CertuAI is the AI that answers a small business's WhatsApp, Instagram and Messenger 24/7, with a built-in CRM, and is a Tech Provider on Meta's WhatsApp Business Platform. Paid plans start at US$ 75 a month, with no lock-in. Create my agent
All the code in one file, ready to publish.
- Reading time: 2 min
- Cost to follow along: R$ 0
- Sign-up: none
CertuAI free manual · Updated September 23, 2026 · Written by Bernardo Villela, CEO of CertuAI · see the whole manual
Summary in 3 facts
- Short answer A single file that receives the message, checks the signature, understands audio and photos, remembers the conversation, blocks made-up prices, calls a person and replies.
- The four secrets go in with npx wrangler secret put: VERIFY_TOKEN, APP_SECRET, WHATSAPP_TOKEN (the permanent key from chapter 3) and GEMINI_API_KEY (aistudio.google.com).
- Then, npx wrangler deploy. wrangler.jsoncjsonc { "name": "atendente", "main": "src/index.js", "compatibility_date": "2026-08-01", // Create with: npx wrangler kv namespace create CONVERSAS // The command prints the id.
By the numbers
| Number | What it means |
|---|---|
| 48 hours | Cake orders: 48 hours in advance. |
| 3 lines | Brazilian Portuguese, at most 3 lines per message, one question at a time. |
How do you put all the manual's code into a single file?
Short answer
A single file that receives the message, checks the signature, understands audio and photos, remembers the conversation, blocks made-up prices, calls a person and replies. Copy, publish and it works.
The file
Paste into src/index.js. You only edit the CEREBRO (the brain) and the price list.
// ─────────────────────────────────────────────────────────────────────
// 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;
}Where people get burned
The alert to the owner only arrives if the owner wrote to the number in the last 24 hours.
Configure and publish
The four secrets go in with npx wrangler secret put: VERIFY_TOKEN, APP_SECRET, WHATSAPP_TOKEN (the permanent key from chapter 3) and GEMINI_API_KEY (aistudio.google.com). Then, 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"
}
}Frequently asked question
Does it run on Node?
Yes. CONVERSAS becomes Redis and waitUntil becomes answer 200 and move on.
And where CertuAI comes in
Only the chapter 6 text is missing, and it comes out free from a conversation at CertuAI.
Build my business text for freeContinue the manual
In short
The single file with all the manual's code together: webhook, signature, AI, memory, audio, photo, price lock and handoff to a human.
About CertuAI: CertuAI is the AI that answers a small business's WhatsApp, Instagram and Messenger 24/7, with a built-in CRM, and is a Tech Provider on Meta's WhatsApp Business Platform. Paid plans start at US$ 75 a month, with no lock-in. Create my agent
