@botozap/sdk
v0.26.0
Published
SDK oficial da BotoZap — envie mensagens e gerencie clientes na WhatsApp Cloud API oficial, multi-tenant.
Maintainers
Readme
@botozap/sdk
SDK oficial da BotoZap para Node.js. Envie mensagens e gerencie clientes na WhatsApp Cloud API oficial, em modelo multi-tenant.
O core da plataforma é fechado. Este cliente é a camada pública sobre a API HTTP: um envelope fino, tipado, sem dependências de runtime.
Status: preview 0.x
- Versão
0.x, sem promessa de estabilidade de API: métodos e tipos podem mudar entre minors até a1.0. Fixe a versão exata no seupackage.json. - A API REST é a interface oficial e estável —
se algo faltar ou quebrar no SDK, o fallback é sempre chamar
https://botozap.com.br/api/v1direto (o SDK é um envelope fino sobre ela). - Bugs e pedidos: issues do monorepo. Nunca inclua sua chave de API no relato.
Sandbox
Uma chave de ambiente sandbox (bz_sandbox_..., criada em /chaves) testa o
fluxo completo sem credencial Meta, sem número real e sem custo: POST /messages
responde 201 com sandbox: true e wamid.sandbox.<...>, e os eventos
simulados percorrem o pipeline real (webhook assinado com retry). Números
mágicos: +5500000000001 (sent→delivered→read, janela de 24h sempre aberta),
+5500000000002 (falha 131026), +5500000000003 (delivered + resposta
inbound que abre a janela real). A chave sandbox só acessa POST /messages,
GET /messages, GET /messages/:id e GET /events — as demais rotas respondem
403 sandbox_forbidden.
Garantias de entrega (leia antes de ir a produção)
Espelha a documentação de confiabilidade:
- Webhooks são at-least-once: duplicatas raras acontecem — deduplique pelo
header
X-Idempotency-Keydo evento recebido. - Sem ordem global: ordene pelo timestamp do evento, não pela chegada.
- Envio não é exactly-once: num erro ambíguo (ex.: timeout depois do envio
aceito pela Meta), o SDK lança
BotoZapErrormas a mensagem PODE ter saído. Em produção, persista sua própria chave de idempotência →wamidantes de reenviar. Transmissões (broadcasts) são at-least-once por natureza.
Instalação
pnpm add @botozap/sdkRequer Node 22.12 ou superior.
Início rápido
import { BotoZap } from "@botozap/sdk";
const boto = new BotoZap({ apiKey: process.env.BOTOZAP_KEY! });
const result = await boto.messages.send({
to: "+5531988887777",
text: "Seu pão tá saindo do forno",
});
console.log(result.wamid, result.status);No Instagram o recibo traz wamid: null, channel: "instagram" e o mid da
Meta em external_id. Para correlacionar envios de qualquer canal, use
result.external_id ?? result.wamid.
A conta é derivada da chave de API. Você nunca passa um id de conta: o isolamento multi-tenant é garantido no servidor.
Mensagens
// Texto
await boto.messages.send({ to: "+5531988887777", text: "Olá!" });
// Template aprovado
await boto.messages.sendTemplate({
to: "+5531988887777",
template: { name: "boas_vindas", language: { code: "pt_BR" } },
});
// Mídia por link (image, video, audio ou document)
await boto.messages.sendMedia({
to: "+5531988887777",
type: "image",
link: "https://cdn.exemplo.com/pedido.jpg",
caption: "Seu pedido ficou pronto",
});
// De um número específico (obrigatório se a conta tem mais de um)
await boto.messages.send({
to: "+5531988887777",
text: "Olá!",
// Aceita o phone_number_id Meta ou o UUID interno retornado pelo SDK.
from: "uuid-ou-phone_number_id",
});
// Responder sem combinar manualmente Contato e origem: no WhatsApp sai pelo
// Número da Conversa; no Instagram, pela Conta de canal (channel_account.id).
await boto.conversations.reply("uuid-da-conversa", {
text: "Resposta do agente",
});
// Botões de resposta, lista ou botão de link (objeto interactive da Cloud API)
await boto.messages.sendInteractive({
to: "+5531988887777",
interactive: {
type: "button",
body: { text: "Confirma o horário?" },
action: {
buttons: [
{ type: "reply", reply: { id: "sim", title: "Sim" } },
{ type: "reply", reply: { id: "nao", title: "Não" } },
],
},
},
});
// Localização
await boto.messages.sendLocation({
to: "+5531988887777",
latitude: -3.119,
longitude: -60.0217,
name: "Loja Centro",
});
// Reação a uma mensagem RECEBIDA (UUID interno ou wamid, até 30 dias); "" retira
const r = await boto.messages.sendReaction({
to: "+5531988887777",
message_id: "wamid.HBgM...",
emoji: "👍",
});
r.reaction?.action; // "react" | "unreact"
// Instagram: o mesmo endpoint, com o IGSID do contato em `to`. `from` aceita o
// id da conta na Meta ou o UUID da Conta de canal (channelAccounts.list).
const ig = await boto.messages.send({
to: "17841400000000123",
text: "Tenho estes horários no sábado:",
// Opções de resposta: só Instagram, só com texto, até 13 (título ≤ 20).
quick_replies: [
{ title: "9h", payload: "sabado_09" },
{ title: "10h30", payload: "sabado_1030" },
],
});
ig.wamid; // null
ig.external_id; // "aWdEZ...mlk" (mid)
// Listar na ordem real das mensagens (histórico importado incluído)
const page = await boto.messages.list({ sort: "event_at", limit: 50 });
page.data[0]?.source; // "api" | "app" | "history" | "broadcast"Interactive, localização e reação são mensagens livres: exigem a janela de 24h
aberta. A API valida o interactive (limites da Cloud API) e recusa com 422
invalid_request; reação recusa com invalid_reaction, missing_reaction_target,
reaction_target_not_found, reaction_target_invalid ou reaction_target_expired.
O cursor de messages.list vale só para o sort que o gerou.
quick_replies só existe no Instagram e só com texto, dentro da janela de 24h:
no WhatsApp a API recusa com channel_not_supported; acima de 13 opções,
too_many_quick_replies; título acima de 20 caracteres,
quick_reply_title_too_long; fora da janela, quick_replies_window_closed.
No Instagram não existe caption em mídia (unsupported_caption).
Botões e cards no Instagram
await boto.messages.sendInteractive({
to: "17841400000000123", from: contaInstagramId,
interactive: { type: "button", body: { text: "Como posso ajudar?" }, action: { buttons: [
{ type: "web_url", title: "Ver loja", url: "https://exemplo.com" },
{ type: "postback", title: "Horários", payload: "horarios" },
] } },
}, { idempotencyKey: chaveDaIntencao });
await boto.messages.sendInteractive({
to: "17841400000000123", from: contaInstagramId,
interactive: { type: "generic", elements: [{
title: "Produto", subtitle: "Disponível na loja",
image_url: "https://exemplo.com/foto.jpg",
default_action: { type: "web_url", url: "https://exemplo.com/produto" },
buttons: [{ type: "postback", title: "Quero saber mais", payload: "produto" }],
}] },
});button aceita 1–3 botões e texto até 640 caracteres; generic aceita 1–10
cards com título (até 80 caracteres) e ao menos uma propriedade adicional.
Subtítulo: até 80 caracteres; até 3 botões por card. Botões usam título de até
20 caracteres e postback de até 1000 caracteres; links são HTTPS. A API valida
os limites (422). tag: "human_agent" é opcional e depende da flag e da
janela autorizada pela API. O recibo traz wamid: null, external_id,
channel: "instagram" e type: "interactive". Os formatos WhatsApp
button com reply, list e cta_url continuam disponíveis.
Contas de canal e Regras de comentário
// Números do WhatsApp e Contas do Instagram lado a lado
const { data: contas } = await boto.channelAccounts.list({ channel: "instagram" });
const conta = await boto.channelAccounts.get(contas[0]!.id); // UUID ou id na Meta
// Saúde da conexão do Instagram (o token nunca é exposto)
conta.instagram?.token_status; // "ok" | "expiring" | "expired" | "invalid" | "revoked" | "unknown"
// Conversas de uma origem específica
await boto.conversations.list({ channel: "instagram", channel_account_id: conta.id });
// Regras de comentário (comment-rules:read / comment-rules:write)
const regra = await boto.channelAccounts.createCommentRule(conta.id, {
keyword: "preço",
dm_text: "Oi! Te mandei os valores por aqui.",
reply_text: "Te chamei no direct!",
});
await boto.channelAccounts.listCommentRules(conta.id);
await boto.channelAccounts.updateCommentRule(conta.id, regra.id, { is_active: false });keyword vazia vale para qualquer comentário e media_id vazio para todos os
posts. Criar ou ativar exige plano com Regras de comentário (422
plan_restricted); a mesma palavra-chave no mesmo alvo é 409 duplicate_rule.
deleteCommentRule(conta.id, regra.id) exclui e retorna { id, deleted: true }.
keywords (até 20 expressões), excluded_keywords (até 20 exclusões),
reply_variations (até 10 respostas públicas) e next_post estão disponíveis
na criação, leitura e atualização. keywords prevalece sobre keyword, e
reply_variations sobre reply_text; [] limpa a lista. next_post: true
aguarda o próximo post e não deve ser combinado com media_id explícito.
Com token_status expired, invalid ou revoked, os envios pela conta
falham até reconectá-la em Canais. Mensagens do Instagram editadas pelo contato
trazem o conteúdo atualizado com content.edited ({ count, at }); as apagadas,
revoked_at. Os Eventos instagram.message.edited e instagram.message.revoked
chegam pelo webhook e por events.list.
Perguntas e menu do Instagram
const perfil = await boto.channelAccounts.getMessengerProfile(conta.id);
await boto.channelAccounts.updateMessengerProfile(conta.id, {
ice_breakers: [{ question: "Quais os horários?", payload: "horarios" }],
persistent_menu: [{ type: "web_url", title: "Ver loja", url: "https://exemplo.com" }],
});
await boto.channelAccounts.updateMessengerProfile(conta.id, { ice_breakers: null });
await boto.channelAccounts.syncMessengerProfile(conta.id);
await boto.channelAccounts.deleteMessengerProfile(conta.id);GET usa messenger-profile:read; PATCH, DELETE e sync usam
messenger-profile:write. Sem sandbox. PATCH preserva seções ausentes;
null ou [] remove a seção. Até 4 perguntas e 5 itens de menu (postback
ou web_url). Perguntas: 80 caracteres; títulos: 20; payloads: 1000; URLs
http/https: 2000. Os quatro métodos retornam o snapshot com
channel_account_id, config, revision, sync_status, synced_at,
last_error_code e updated_at. GET lê o espelho salvo; sync verifica/reaplica
explicitamente o desejado. Falhas de aplicação conservam a configuração para
retry; profile_busy e profile_sync_unconfirmed pedem reconciliar após um
minuto. Sync sem configuração salva retorna profile_not_configured.
Perguntas e menu são do plano pago e do teste: no Free, salvar conteúdo
(PATCH) ou reaplicar uma configuração com conteúdo (sync) responde
403 plan_restricted; remover seções e DELETE seguem livres.
Clientes e templates
const { data: customers, meta } = await boto.customers.list({ per_page: 20 });
const customer = await boto.customers.get("uuid-do-cliente");
const { data: templates } = await boto.templates.list();Mídia
Para enviar por link, use messages.sendMedia. O SDK só envia o contrato ao
endpoint canônico; não baixa o arquivo no processo do cliente:
await boto.messages.sendMedia({
to: "+5531988887777",
type: "document",
link: "https://cdn.exemplo.com/fatura.pdf",
caption: "Fatura de agosto",
filename: "fatura-agosto.pdf",
});caption é aceito em image, video e document; audio aceita apenas o
link; filename é exclusivo de document.
Para fazer ingest de um arquivo, a BotoZap baixa da URL com guard SSRF e envia à Cloud API:
const up = await boto.media.upload({
source: "https://exemplo.com/foto.png",
phone_number_id,
});
// up.target.media_id → usável no envioPara uma mídia recebida, busque metadados e a URL de download pelo media_id
da Cloud API. A mídia é espelhada de forma assíncrona: se ainda não terminou, o
método lança BotoZapError com code: "media_not_ready" (202) e você retenta,
respeitando o Retry-After:
try {
const media = await boto.media.get(mediaId);
// media.download_url é uma URL assinada e efêmera — baixe direto (GET).
} catch (err) {
if (err instanceof BotoZapError && err.code === "media_not_ready") {
const wait = Number(err.headers?.["retry-after"] ?? 5);
// aguarde `wait` segundos e tente de novo
}
}Retornos e paginação
O SDK desempacota o envelope { data } da API pra você:
- Item (
get,create,update,sendde broadcast…): devolve a entidade direto —boto.customers.get(id)resolve paraCustomer, não{ data: Customer }. - Lista: devolve o objeto com os itens e a paginação —
{ data, paging }(cursor) ou{ data, meta }(offset/página). Nunca só o array. messages.send/sendTemplate: essa rota responde o objeto direto (sem envelope), então você recebeSendResultcru.delete: responde204— retornovoid.
Dois modelos de paginação, conforme a coleção:
// Cursor (contacts, conversations, webhooks, apiLogs, webhookDeliveries,
// broadcasts.listRecipients): params { limit, after, before }.
let page = await boto.contacts.list({ limit: 50 });
if (page.paging.next) {
page = await boto.contacts.list({ limit: 50, after: page.paging.next });
}
// Offset/página (customers, templates, broadcasts, phoneNumbers,
// channelAccounts, users, conversations.listAssignments): params { page, per_page }.
const { data, meta } = await boto.templates.list({ page: 2, per_page: 20 });
console.log(meta.total_count, meta.total_pages);Tratamento de erro
Respostas fora de 2xx viram BotoZapError, com o code e a message do envelope da API.
import { BotoZap, BotoZapError } from "@botozap/sdk";
try {
await boto.messages.send({ to: "+5531988887777", text: "Olá!" });
} catch (err) {
if (err instanceof BotoZapError) {
console.error(err.code, err.status, err.message);
}
}Quando houve resposta HTTP (status > 0), o erro expõe também err.headers (os headers da resposta). Útil num 429, para ler Retry-After / X-RateLimit-* e decidir quando reenviar:
catch (err) {
if (err instanceof BotoZapError && err.status === 429) {
const retryAfter = err.headers?.["retry-after"];
// aguarde `retryAfter` segundos antes de tentar de novo
}
}err.headers traz só os headers da resposta — nenhuma credencial de request entra nele.
Quando a Meta recusa um envio (messages.send e afins, conversations.reply e
conversations.requestConsent), o erro chega como meta_error e
err.metaCode traz o código numérico da Meta (por exemplo, 131026), se ela
informou um. Use-o para consultar a causa na documentação de erros da Meta.
catch (err) {
if (err instanceof BotoZapError && err.metaCode !== undefined) {
console.error(`Meta recusou com o código ${err.metaCode}: ${err.message}`);
}
}Recusas de plano
isPlanError(err) reconhece recusas que só um upgrade resolve (retry não):
403 plan_restricted nas escritas de IA, Agenda, Calendário, Jornadas, execuções,
Regras de mensagem do Direct e Perguntas e menu do Instagram
(422 plan_restricted em funil, aquecimento e regras de comentário),
429 free_form_limit_reached (limite de mensagens 1:1 do Free),
402 free_number_cap (setup além do teto de Números do Free ou envio por um
Número suspenso) e 402 free_cap (2º Cliente no Free ou envio por um Cliente
suspenso). O catálogo de IA tem só openai e anthropic
(AI_CREDENTIAL_PROVIDERS, tipo AiEnabledProvider): as entradas (credencial,
vínculo, modelos, tarifa, agente) só aceitam esses dois; qualquer outro provedor
responde 422 provider_not_enabled, também ao reativar a credencial (update com
active: true), trocar a chave, revalidate, vincular o provedor e salvar agente
com provedor descontinuado. ai.providers.get() lista só os dois provedores; as
leituras de credenciais, vínculos e agentes antigos podem trazer, desativados, os
provedores descontinuados (AiProvider mantém os seis).
import { isPlanError } from "@botozap/sdk";
catch (err) {
if (isPlanError(err)) mostrarUpgrade(err.code);
}Acima dos limites do Free
O Free permite 1 Cliente (com canal conectado ou não) e 1 número de WhatsApp.
Quem passa disso, por exemplo depois do teste grátis, não é apagado nem
desconectado: fica suspenso. As leituras de customers e phoneNumbers
(list, get e o update do número) trazem plan_suspended: true. O recurso
suspenso continua recebendo mensagens, mas não envia: o envio responde
402 free_cap (Cliente suspenso) ou 402 free_number_cap (Número suspenso; o
Número de um Cliente suspenso também vem com plan_suspended: true). Criar o 2º
Cliente no Free responde 402 free_cap. Assinar libera tudo na hora; para
seguir no Free, desconecte o outro número ou apague o Cliente que não usa.
import { isFreeCap, isFreeNumberCap } from "@botozap/sdk";
const numeros = await boto.phoneNumbers.list();
const ativos = numeros.data.filter((n) => !n.plan_suspended);
catch (err) {
if (isFreeNumberCap(err)) avisar("Número suspenso no Free: assine ou desconecte o outro número.");
else if (isFreeCap(err)) avisar("Negócio suspenso no Free: assine ou apague o outro Cliente.");
}Plano e slots adicionais
O plano pago inclui 1 negócio. Cada negócio a mais ocupa um slot adicional de
R$ 47/mês, com assinatura própria. Estas rotas aceitam só chave de API, com os
scopes billing:read e billing:write (OAuth não tem acesso).
const { plan, can_add_slot, slots } = await boto.billing.get();
// Compra um slot vazio (gera cobrança de R$ 47/mês). A mesma idempotencyKey
// devolve o mesmo slot em vez de comprar outro.
const slot = await boto.billing.slots.create({ idempotencyKey: "compra-2026-10-17" });
// Cancela um slot vazio, sem reembolso.
await boto.billing.slots.cancel(slot.id);Recusas da compra: 402 plan_required (Free ou teste: assine o plano),
402 slot_limit (já há 2 slots com fatura em aberto: pague uma) e
402 billing_customer_required (conta sem cadastro de cobrança: fale com o
suporte). Cancelar slot ocupado responde 409 slot_in_use: desconecte o negócio
antes. invoice_url pode vir null logo depois da compra; leia de novo em
billing.get().
Cada link de setup traz connection_billing, que diz o que acontece quando o
Cliente conectar (included, occupies_slot, creates_slot ou refused, com
message pronta para mostrar). Cada Cliente traz business_slot (null quando
está no que o plano inclui).
Quando a fatura de um slot vence há mais de 7 dias, o negócio fica pausado e o
envio responde 402 business_payment_required. Pagar libera na hora; não é
recusa de plano (isPlanError é false).
import { isBusinessPaymentRequired } from "@botozap/sdk";
catch (err) {
if (isBusinessPaymentRequired(err)) avisarFaturaVencida();
}Consentimento e recusa
Cada contato tem um consentimento por canal (whatsapp ou instagram) e
finalidade (marketing ou utility), com a origem de cada registro: manual,
import, api, keyword (Palavra de saída), conversation_button (botão do
pedido de consentimento), contact_keyword (Palavra de volta), template_button
(botão de saída do template), meta_opt_out (recusa informada pela Meta) e
owner_declaration (declaração do dono num follow-up de Marketing). Só o
próprio contato volta a aceitar depois de recusar.
const { current, history } = await boto.contacts.listConsents(contactId);
await boto.contacts.recordConsent(contactId, {
channel: "whatsapp",
purpose: "marketing",
state: "granted",
evidence: "Aceitou no formulário do site",
});
// Pedido de consentimento na conversa (botões no WhatsApp, respostas rápidas no
// Instagram; só com a janela de 24h aberta). A resposta traz `channel`.
const pedido = await boto.conversations.requestConsent(conversationId, {
purposes: ["marketing"],
});Recusas: 409 consent_opted_out ao registrar aceite sobre uma recusa do contato
(nada é gravado; isConsentOptedOut), 422 consent_revoked ao enviar template de
marketing a quem recusou, também por outro número da Conta (isConsentRevoked;
consent_basis em sendTemplate não desfaz a recusa), outside_window (WhatsApp)
ou window_closed (Instagram) no pedido de consentimento com a janela fechada,
channel_not_supported, e 409 not_connected quando o número está desconectado
(isNotConnected; vale também no envio, em mídia, transmissão e criação de
template). contacts.create aceita consents iniciais; recriar um contato que
recusou responde 201 com warnings (consent_opted_out ou consent_unconfirmed)
e o contato nasce como Recusou. Excluir o contato (contacts.delete) apaga a
mídia dele e guarda a recusa só como código cifrado (hash), que vale se ele voltar.
As Palavras de saída e de volta e os textos de confirmação são do Cliente
(customers.update com opt_out_keywords, opt_in_keywords,
opt_out_confirmation e opt_in_confirmation; não existe lista padrão). Num
template, templates.update(id, { opt_out_buttons }) (ou opt_out_buttons em
templates.create) marca os botões de resposta rápida que são de saída.
Responder citando, voz e "digitando…"
// "Digitando…" enquanto prepara a resposta (só com mensagem recebida e janela aberta;
// repetir em menos de 20 s não chama a Meta de novo) e marcar como lida.
await boto.conversations.typing(conversationId);
await boto.conversations.markRead(conversationId);
// Responder citando uma mensagem da mesma conversa (UUID ou wamid/mid).
await boto.messages.send({ to, text: "Combinado!", reply_to: { message_id: wamid } });
await boto.conversations.reply(conversationId, { text: "Sim", reply_to: { message_id: wamid } });
// Mensagem de voz (WhatsApp; .ogg com Opus mono). "Ouvida" chega em
// whatsapp.message.played e em Message.played_at; o status continua read.
await boto.messages.sendMedia({ to, type: "audio", link: "https://cdn.exemplo.com/oi.ogg", voice: true });Recusas: no_inbound_message (409, "digitando…" sem mensagem do contato),
reply_target_not_found e reply_target_unavailable (422), invalid_voice e
unsupported_voice (422).
Conversas antigas do app WhatsApp Business
Número em coexistência (também no app WhatsApp Business do celular) pode trazer as conversas antigas logo depois de conectar: 0 (nada), 30 ou 180 dias.
const historico = await boto.phoneNumbers.getHistoryImport(id); // status, window_days, can_choose, deadline_at
await boto.phoneNumbers.setHistoryImport(id, { window_days: 180 });A escolha vale uma vez por conexão, enquanto can_choose; repetir a mesma
escolha devolve 200 e outra responde 409 history_import_locked. Sem escolha,
o último mês (30 dias) vale 20 h depois da conexão. Número sem importação
responde 404 history_import_not_available.
Configurações do número do WhatsApp
Perfil, perguntas prontas e comandos, QR codes e bloqueados, direto na Meta (sem
sandbox). {id} aceita o UUID interno ou o phone_number_id da Meta.
const perfil = await boto.phoneNumbers.getProfile(id); // editable=false em coexistência
await boto.phoneNumbers.updateProfile(id, { about: "Atendemos de segunda a sábado" });
await boto.phoneNumbers.uploadProfilePhoto(id, { file: blob, mime_type: "image/png" });
await boto.phoneNumbers.updateConversationalAutomation(id, { prompts: ["Ver horários"] });
const qr = await boto.phoneNumbers.qrCodes.create(id, { prefilled_message: "Oi!" }); // não é idempotente
await boto.phoneNumbers.blockedUsers.block(id, { contact_id }); // só quem escreveu nas últimas 24 hMudar o perfil por token OAuth responde 403 (só painel e chave de API); em
coexistência, 409 coexistence_not_supported. Gravar perguntas ou comandos
exige plano com automações (403 plan_restricted).
Excluir conversa
conversations.delete(id) exclui a conversa sem volta, com efeito imediato:
mensagens, notas, atribuições, vínculos com o CRM e o que a IA guardou dela, e a
mídia. O contato fica. Só Administrador; responde 204.
Exportação de dados e registro de atividades
Scopes exports:read/exports:write e team:read; no OAuth, só pelo painel.
// Conta inteira ou um Cliente; o arquivo fica 7 dias, num zip por tipo de dado.
const pedidoExport = await boto.exports.create({ customer_id, format: "csv" });
const pronto = await boto.exports.get(pedidoExport.id); // pronto.download.url vale poucos minutos
const recentes = await boto.exports.list();
// Quem fez o quê, quando e de que IP. Só Administrador (403 admin_required).
const { data: eventos } = await boto.teamEvents.list({
action: "export.requested",
target_type: "conversation",
since: "2026-10-01T00:00:00Z",
});billing.get() traz também guarantee (garantia de 7 dias: ciclo, valor e prazo
final) enquanto o cancelamento ainda devolve a primeira mensalidade integral.
Chave com lista de IPs autorizados responde 403 ip_not_allowed a chamadas de
outro IP, e rotas removidas respondem 410 gone (por exemplo, as de confirmação
de uso de dados da IA, que não existem mais).
Webhooks
WEBHOOK_CATEGORIES lista as categorias de webhooks.create({ events }):
messages, statuses, crm, account (inclui whatsapp.phone_number.connected)
e app_messages, opt-in, com whatsapp.message.echo, .edited e .revoked do
app WhatsApp Business em coexistência. Em webhookDeliveries.list, o status
limited marca a entrega cortada pelo limite de repasse do Free (sem retry).
webhookDeliveries.replay(id) reenvia uma entrega exhausted (esgotada) ou
limited, como o botão Reenviar do painel: ela volta para a fila
({ id, status: "pending", replayed: true }) e o evento sai de novo com a mesma
X-Idempotency-Key. Repetir não duplica: a entrega que já voltou para a fila
responde 409 delivery_not_replayable. Outras recusas: 404 not_found,
409 forward_limit_reached (entrega limited e o limite de repasse do mês
acabou) e 429 replay_rate_limited (headers de rate limit em err.headers).
Exige o escopo webhooks:write.
const { data } = await boto.webhookDeliveries.list({ status: "exhausted", limit: 1 });
if (data[0]) await boto.webhookDeliveries.replay(data[0].id);channel_account_ids limita um Endpoint a alguns canais (ids de
channelAccounts.list, os mesmos de channel_account.id nos payloads; 1 a
100). No create, omitido ou null = todos os canais. No update, omitido não
muda, null volta a todos e um array substitui a lista. A leitura devolve
channel_account_ids (null = todos; vazio quando os canais marcados foram
excluídos, e o Endpoint então não recebe eventos) e channels_health, só com
os canais em falha (channel_account_id: null = eventos sem canal). A espera,
as falhas seguidas e a pausa (na 15ª falha) contam por canal: um canal pausado
não para os outros. health_status e failure_streak do Endpoint resumem o
pior canal; paused só aparece quando todos os canais com falha estão
pausados. Erros: 422 invalid_channel_account_ids e
404 channel_account_not_found.
await boto.webhooks.update(endpointId, { channel_account_ids: [canalId] });
await boto.webhooks.update(endpointId, { channel_account_ids: null }); // todos
const { channels_health } = await boto.webhooks.get(endpointId);Configuração
new BotoZap({
apiKey: "...",
baseUrl: "https://botozap.com.br/api/v1", // padrão
fetch: customFetch, // opcional
});
baseUrlexiste para testes e ambientes controlados. A chave de API é enviada comoAuthorization: Bearerpara o host configurado — nunca apontebaseUrl/BOTOZAP_API_URLpara um host que não seja de sua confiança.
Recursos
O SDK cobre os recursos da API /v1:
messages— enviar texto (comquick_repliesno Instagram), template e mídia por link, listar e buscar por id;reply_to(responder citando) em todos os envios menos reação,voice: trueem áudio (mensagem de voz); leituras trazemreply_to,played_atetranscriptioncustomers— listar, buscar, criar, atualizar (nome, id externo e Palavras de saída/volta com os textos de confirmação), remover, e links de setup (listar/criar/atualizar;channelwhatsappouinstagram, um canal por link)templates— listar, buscar, criar, atualizar (opt_out_buttons)broadcasts— criar, destinatários, agendar, enviar, cancelarcontacts— listar, buscar, criar, atualizar, remover,listConsents/recordConsent;display_nameé o nome que a empresa dá ao Contato (1–200 caracteres;nulllimpa), separado doprofile_nameque vem do canalconversations—typing("digitando…") emarkRead(marcar como lida);requestConsentedelete;replyaceitareply_to; listar (filtroschannelechannel_account_id), buscar, atualizar, responder, atribuições; leituras trazementry_point(ctwa/organic/null),referraldo último clique em anúncio Click-to-WhatsApp,fep_expires_at(fim da janela grátis informado pela Meta) efep_reply_by(estimativa do prazo para responder)webhooks— CRUD e teste;customer_idopcional limita as entregas a um Cliente da Conta (nullno update remove o filtro);channel_account_idslimita a alguns canais (null= todos) echannels_healthtraz a saúde por canalchannelAccounts— listar e buscar Contas de canal (WhatsApp e Instagram); Regras de comentário do Instagram (listar, buscar, criar, atualizar, excluir) e Perguntas e menu (ler, salvar/aplicar, remover, verificar/reaplicar)phoneNumbers— listar, buscar, atualizar olabel(nome local, até 100 caracteres;nulllimpa), remover, saúde; configurações do número do WhatsApp:getProfile/updateProfile/uploadProfilePhoto,get/update/clearConversationalAutomation(perguntas prontas e comandos),qrCodes.list/create/get/update/deleteeblockedUsers.list/block/unblock; conversas antigas do app WhatsApp Business (coexistência):getHistoryImport/setHistoryImportmedia— subir arquivo (obter um media_id) e buscar metadados + URL de download de uma mídia recebidaevents— reler inbound e mudanças de status pelo cursor durável da Conta/ambienteusage—metaCosts: custo aproximado da Meta por dia, categoria e moeda (Pricing Analytics), com estimativa por tarifa publicada onde a Meta não devolveu custobilling—get: plano e slots adicionais;slots.create(compra, comidempotencyKey) eslots.cancel(slot vazio, sem reembolso);guarantee(garantia de 7 dias). Só chave de APIexports—create,list,get(exportação de dados da Conta;exports:read/exports:write)teamEvents—list: registro de atividades da equipe, com filtros (team:read, só Administrador)me—get: Conta, ambiente (liveousandbox) e scopes efetivos da chave autenticada; não retorna a chave nem uma identidade pessoalwebhookDeliveries— histórico de Entregas de webhook e reenvio das esgotadas (replay)users,apiLogs— leitura
me.get() é útil para conferir a identidade configurada antes de executar
operações em uma integração:
const identity = await boto.me.get();
// { account_id: "…", environment: "live", scopes: ["messages:write"] }Inbound e mudanças de status compartilham um stream crescente. Guarde
paging.cursor e use-o como after na leitura seguinte; após uma desconexão,
isso recupera o intervalo persistido sem depender de memória do processo:
const page = await boto.events.list({ after: ultimoCursor, limit: 100 });
for (const event of page.data) {
console.log(event.cursor, event.type, event.data);
}
ultimoCursor = page.paging.cursor;Cada Evento traz message_id (o wamid, só no WhatsApp; null em outros
canais) e external_id (o id da mensagem no canal de origem: wamid no
WhatsApp, mid no Instagram). Para correlacionar mensagens de qualquer canal,
use external_id; ambos podem ser null em Eventos sem mensagem.
Passe um AbortSignal quando a leitura fizer parte de um tail cancelável:
const controller = new AbortController();
const page = await boto.events.list({
after: ultimoCursor,
signal: controller.signal,
});
controller.abort(); // interrompe o I/O se a assinatura deixou de existir// Transmissão: template_name + template_language são obrigatórios.
await boto.broadcasts.create({
name: "Promo de sexta",
phone_number_id,
template_name: "promo_sexta",
template_language: "pt_BR",
});
// Destinatários: objetos { to_recipient, components? } (até 1000 por chamada,
// só em draft). Strings soltas NÃO são aceitas. Itens inválidos voltam em
// `errors` ({ index, to_recipient?, reason }) sem derrubar o lote.
const { added, duplicates, errors } = await boto.broadcasts.addRecipients(broadcastId, [
{ to_recipient: "5592999990000" },
{
to_recipient: "5592988880000",
components: [{ type: "body", parameters: [{ type: "text", text: "Ana" }] }],
},
]);
// Criar um template (submetido à Meta para análise).
await boto.templates.create({
name: "boas_vindas",
language: "pt_BR",
category: "MARKETING",
components: [{ type: "BODY", text: "Olá {{1}}, seja bem-vindo!" }],
phone_number_id,
});
// Listar mensagens (paginação por cursor + filtros).
const msgs = await boto.messages.list({ limit: 50, direction: "inbound" });
// Cliente + link de setup (Embedded Signup).
await boto.customers.update(customerId, { name: "Padaria do João" });
const link = await boto.customers.createSetupLink(customerId, {
allowed_connection_types: ["dedicated"],
// https, sem usuário/senha, até 2048 caracteres (senão 422 invalid_redirect_url).
// Concluído → success com ?setup_link_id=…&status=completed. Link esgotado →
// failure com status=failed; cliente volta num erro recuperável → failure com
// status=cancelled (o link segue válido). A query da URL é preservada e o
// token do link nunca é enviado.
success_redirect_url: "https://seu-app.com/whatsapp/ok?ref=42",
failure_redirect_url: "https://seu-app.com/whatsapp/erro",
});
// Envie link.url ao Cliente. No sucesso, confirme o número em phoneNumbers.list:
// uma conexão concluída ainda pode ter pendência de registro.
// Cada link conecta um canal só: channel "whatsapp" (padrão) ou "instagram"
// (outro valor → 422 invalid_channel). No Instagram, o Cliente confirma que a
// conta é profissional e que as mensagens estão liberadas e entra com o
// Instagram. allowed_connection_types não vale (enviar → 422
// invalid_connection_types); o link volta com allowed_connection_types: [] e
// url em /instagram/setup/<token>. whatsapp_setup_status vale para os dois
// canais e passa a completed quando a conta do Instagram conecta.
const linkIg = await boto.customers.createSetupLink(customerId, {
channel: "instagram",
success_redirect_url: "https://seu-app.com/instagram/ok", // status=completed
failure_redirect_url: "https://seu-app.com/instagram/erro", // status=cancelled
});
// linkIg.channel === "instagram"
await boto.webhooks.create({
url: "https://seu-app.com/hooks",
events: ["messages", "statuses"], // categorias aceitas pela API (a rota rejeita nomes finos)
customer_id, // opcional: só entregas deste Cliente (omitido = todos da Conta)
});
const { data: numeros } = await boto.phoneNumbers.list({ customer_id });
await boto.phoneNumbers.update(numeros[0]!.id, { label: "Recepção" });
// Custo da Meta (aproximado; a fatura da Meta é a autoridade). Custo ausente
// vem null, nunca 0 — veja `unavailable_reason` e `estimated_cost`.
const custos = await boto.usage.metaCosts({ customer_id, from: "2026-09-01", to: "2026-09-25" });Exemplos
Exemplos mínimos e rodáveis na raiz do monorepo, em examples/:
node-javascript— envio sandbox e tratamento deBotoZapErrornode-typescript— envio tipado, leitura paginada e erro estruturadowebhook-receiver— receber os eventos encaminhados (validaX-Webhook-Signature)mcp-sandbox— servidor MCP com chave sandbox
Licença
MIT
Atendimento, CRM e Agenda
Recursos tipados: savedReplies, inbox, opportunities, demands, radar,
journeys, appointments (inclui services, schedules, exceptions),
calendar, contactStages e contactFields. As edições preservam os campos CAS
exigidos pela API. Notas internas nunca enviam mensagem ao contato; rascunhos e
respostas pessoais pertencem à sessão do Painel. Autorize Google Calendar no
Painel antes de gerenciar conexões pelo SDK.
Exemplo: client.savedReplies.update(id, { body, expected_updated_at: row.updated_at }).
Modelos prontos de Régua: client.journeys.catalog() lista os modelos da galeria
e client.journeys.enableModel(key, { customer_id }) liga um no Cliente (cria e
submete o template; a Régua liga sozinha quando a Meta aprova).
Não arredonde updated_at: preserve a precisão recebida do servidor.
Criação de compromisso aceita segundo argumento { idempotencyKey: 'chave-estavel' }.
Contratos, exemplos e roteiro de release.
IA usa exclusivamente credenciais próprias (BYOK): SDK client.ai, CLI botozap ai
e ferramentas MCP ai_*. Agentes versionados, provedores, credenciais, conhecimento,
memória, skills, follow-ups e retornos prometidos, roteadores, casos, alertas,
avisos, propostas de aprendizado e comerciais, controle de acesso (elegibilidade),
inferências, promessas do operador, catálogo de modelos, execuções e uso. Scopes
agents:read/write; aprovação exige chave criada por
owner/admin ainda autorizado. Prévia não envia WhatsApp. Não há carteira, créditos
ou compra de vagas de IA. Contratos e exemplos IA.
Tarifas próprias de transcrição
client.ai.usage.saveRate aceita audio_pricing separado das tarifas de texto.
Omitir preserva a configuração anterior; null remove a tarifa de áudio.
Informe os preços do contrato da sua chave, sem assumir valores padrão:
{ unit: "duration", usd_per_minute }cobra por duração verificada.{ unit: "tokens", input_audio_usd_per_million, input_text_usd_per_million, output_text_usd_per_million, max_input_tokens, max_output_tokens }usa as unidades de áudio/texto informadas pelo provedor.
Preços: 0–100.000, até oito casas decimais. Reservas de tokens: inteiros de
1–100.000.000. São estimativas para admissão, não limites técnicos impostos ao
provedor; o custo real pode superar a reserva. Cada chamada preserva sua tarifa.
A ausência de preço ou de medição compatível mantém o custo desconhecido e pode
bloquear novas chamadas. usage.reprice, com confirmação explícita, também estima
transcrições anteriores com unidades compatíveis e sem tarifa registrada; não
substitui snapshots nem transforma uma inferência incerta em custo zero.
A CLI ai usage save-rate recebe o mesmo JSON por --input-file; o MCP expõe
audio_pricing opcional e anulável na ferramenta ai_usage_save_rate.
Token OAuth
Além de apiKey, o cliente aceita accessToken. Um provider permite usar o token renovado em cada requisição:
const boto = new BotoZap({
accessToken: async () => getCurrentOAuthAccessToken(),
});
await boto.contacts.list();Informe uma única credencial. O SDK envia Authorization: Bearer e não realiza o fluxo de login ou a renovação; o aplicativo fornece o token atual. boto.me.get() devolve os scopes e, para OAuth, auth_type, user_id, client_id, grant_id e allowed_routes. A autorização sempre é revalidada pelo servidor.
Repetir um envio sem duplicar a intenção
A opção idempotencyKey é opcional. Derive a chave do evento no seu sistema
(o pedido, a assinatura, a data do aviso), para que qualquer processo que tente
o mesmo envio use a mesma chave; o corpo e a credencial também devem ser os
mesmos. Sem a opção, o SDK mantém o comportamento anterior.
const options = { idempotencyKey: `pedido-pronto-${pedido.id}` };
await boto.messages.send({ to: "5511999999999", text: "Seu pedido está pronto." }, options);
// Retry da mesma intenção: use novamente o mesmo payload e options.Uma chave aleatória (crypto.randomUUID()) só protege se for criada e
guardada antes da primeira tentativa: dois processos que geram cada um a sua
não se enxergam, e as duas mensagens saem.
sendTemplate, sendMedia, sendInteractive, sendLocation, sendReaction e
conversations.reply aceitam a mesma opção como último argumento. O servidor retorna a resposta original; payload diferente ou
tentativa pendente retornam 409. Resultados terminais ficam disponíveis por 24h.
Pendências desconhecidas não reabrem automaticamente. Depois de 24h, confira o
histórico antes de reenviar; nunca troque a chave para contornar uma pendência.
Requer a API com suporte a Idempotency-Key em POST /v1/messages.
Recusas confirmadas (outcome: rejected) liberam a chave. Aguarde o backoff ou
corrija a causa e repita com a mesma chave; após liberação, outro payload é uma
nova intenção. unknown ou accepted exigem conciliação antes de considerar
outro envio. SDK e MCP preservam outcome, retry e o código da Meta (metaCode) da API. Resultados concluídos
vencidos são removidos pelo cron a cada minuto; claims incertas não são removidas
por tempo, conforme o runbook botozap/docs/ops/message-send-receipts.md.
Instagram — onda 2
Regras de comentário incluem dm_button_title, delivery_text e delivery_media.
Regras de mensagem têm CRUD com gatilhos e ações tipados (message-rules:read/write);
no Free, criar ou editar responde 403 plan_restricted (pausar com
{ is_active: false } e excluir seguem livres);
métricas de comentário recebem from/to (comment-rules:read).
Contrato e exemplos de SDK, CLI e MCP.
Instagram: conexão, QR, lives e seguidores
const connection = await boto.channelAccounts.getConnection(channelAccountId);
await boto.channelAccounts.resubscribeConnection(channelAccountId);
// Chave de API; no OAuth, a desconexão é feita pelo painel.
await boto.channelAccounts.disconnect(channelAccountId);
const svg = await boto.channelAccounts.getMessageRuleQr(channelAccountId, ruleId, { format: "svg" });
// ArrayBuffer; formato padrão: png. Não contém envelope JSON.
const followers = await boto.contacts.list({ ig_follows_business: true });
const nonFollowers = await boto.conversations.list({ ig_follows_business: false });As Regras de mensagem retornam ref_link (https://ig.me/m/usuario?ref=... ou null). Regras de comentário aceitam live; as métricas incluem posts[].live. Servidores sem lives habilitadas devolvem 422 feature_unavailable. Não combine live: true com media_id ou next_post.
Ambos os tipos de regra aceitam require_follow e non_follower_text. Nas regras de comentário, a condição exige botão e texto para quem não segue (até 640 caracteres); nas de mensagem, esse texto é opcional e limitado a 1.000 bytes. null limpa o texto; campos ausentes no PATCH preservam o valor. Contatos expõem somente para leitura ig_follows_business, ig_followed_by_business, ig_follower_count, ig_verified e ig_follow_checked_at; valores desconhecidos são null e ficam fora dos filtros true e false. Eventos instagram.message_rule.failed podem ter message_id: null quando vêm de referral sem mensagem.
IA de comentários do Instagram
const config = await boto.channelAccounts.getCommentAi(channelAccountId);
await boto.channelAccounts.updateCommentAi(channelAccountId, {
enabled: true, agent_id: publishedAgentId, daily_limit: 50,
});
const runs = await boto.channelAccounts.listCommentAiRuns(channelAccountId, { page: 1, per_page: 30 });
await boto.channelAccounts.updateCommentAi(channelAccountId, { enabled: false });Leitura e histórico exigem agents:read; PATCH exige agents:write e capacidade
ai do plano, exceto o patch que somente desliga. Sem sandbox; no OAuth, PATCH
é exclusivo do painel. Ativar pode publicar respostas e enviar DMs automáticas.
Campos ausentes preservam a configuração; agent_id: null limpa o agente quando
desligada. daily_limit aceita inteiros de 1 a 200; o dia usa America/Sao_Paulo.
answered_today conta respostas públicas confirmadas e reserved_today inclui
tentativas incertas. status, gate_mode e readiness descrevem a prontidão.
InstagramCommentAi e InstagramCommentAiRun tipam os retornos. Execuções deste
recurso incluem estimated_cost_usd?: number | null e finish_reason com prefixo
comment_ai_; source continua live. InstagramCommentAiRepliedEvent tipa
instagram.comment.replied com automation.kind = instagram_comment_ai,
execution_id e comment_id. A Private Reply em instagram.message.sent traz
source: agent, ai_execution_id e comment_id no conteúdo da mensagem.
