Gestão de contatos
Toda mensagem que entra ou sai vira contato na sua base — um CRM leve embutido no gateway. O bZapper captura o contato automaticamente da conversa (nome do WhatsApp, avatar, atividade) e você enriquece com os campos de CRM (e-mail, documento, endereço), organiza com tags e grupos de contato, filtra por qualquer critério e acompanha a timeline de cada pessoa.
Aqui tratamos da base de contatos (pessoas) e dos grupos de contato (segmentos
de CRM), que vivem em /contacts e /contact-groups. Não confunda com os grupos de
WhatsApp (salas de conversa), que ficam em /groups. São coisas diferentes.
Correlação automática
Você não precisa cadastrar nada para começar: a cada mensagem trocada, o bZapper correlaciona o contato ao projeto e ao número que falou com ele. O contato ganha:
instance_id— o último número que conversou com ele (útil para afinidade/sticky).last_message_atemessage_count— atividade viva.source— como entrou na base:inbound,outbound,api,importouwidget.nameeavatar_url— nome de exibição (push name) e foto, best effort.
O telefone é sempre normalizado (+DDIdigits, sem espaços) e o e-mail vira minúsculo
no banco — o mesmo contato é reconhecido sem duplicatas.
Carimbar tags e grupos no envio
Os campos groups[] e tags[] de qualquer envio carimbam o contato
destinatário quando a mensagem resolve — sem uma chamada extra ao CRM. Chaves
desconhecidas são criadas no dicionário na hora.
curl -X POST https://api.bzapper.com.br/messages/text \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511999998888",
"body": "Bem-vindo! 🎉",
"tags": ["lead-quente"],
"groups": ["onboarding"]
}'
groups[] no envio NÃO manda para grupo de WhatsAppgroups[] correlaciona o contato a grupos de contato (CRM). Para enviar a uma sala
do WhatsApp, use o JID do grupo no campo to.
Perfil do contato
Cada contato tem campos de CRM em colunas separadas — nada de "notas soltas":
| Campo | O que é |
|---|---|
phone | telefone +DDIdigits (obrigatório na criação) |
name | nome (push name do WhatsApp ou o que você informar) |
email | e-mail (armazenado em minúsculo) |
document / document_type | documento (CPF/CNPJ/passaporte…) e seu tipo |
address | endereço em campos separados: street, number, complement, district, city, state, zip, country |
tags / groups | chaves de tags e grupos de contato correlacionados |
status | estado do ciclo de vida (veja abaixo) |
instance_id | último número que conversou com ele |
message_count, last_message_at, created_at, updated_at | atividade |
Estados (status)
| Estado | Significado |
|---|---|
active | ativo, recebe normalmente |
pending_validation | ainda não validado |
opted_out | pediu descadastro (LGPD) — bloqueado para envio |
blocked | bloqueado manualmente por um admin |
unreachable | problema de recebimento (bounce/inferido) |
Criar e atualizar
# Criar (phone obrigatório; o resto é opcional)
curl -X POST https://api.bzapper.com.br/contacts \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+5511999998888",
"name": "Vinicius Berni",
"email": "[email protected]",
"document": "12345678900",
"document_type": "cpf",
"address": { "city": "Porto Alegre", "state": "RS", "country": "BR" }
}'
Resposta 201:
{
"id": "3f2a…",
"phone": "+5511999998888",
"name": "Vinicius Berni",
"email": "[email protected]",
"document": "12345678900",
"document_type": "cpf",
"address": { "city": "Porto Alegre", "state": "RS", "country": "BR" },
"status": "pending_validation",
"source": "api",
"message_count": 0,
"tags": [],
"groups": [],
"created_at": "2026-07-01T12:00:00Z",
"updated_at": "2026-07-01T12:00:00Z"
}
pending_validationContato criado à mão não entra como active — ele nasce pending_validation e
nada o promove sozinho. Como campanhas e seleção em massa só aceitam
status = active, chame POST /contacts/{id}/optin depois de criar (é o registro
de consentimento) para que ele possa receber. Um contato que já conversou com você
entra como active sozinho.
Criar com um telefone que já existe não dá erro: é um upsert. A API responde
201 com o contato existente, preenchendo apenas os campos que estavam vazios —
ela não sobrescreve nome, e-mail ou documento já preenchidos. Para alterar o que
já existe, use PATCH /contacts/{id} (atualização parcial: mande só os campos que
mudaram).
Listar com filtros avançados
GET /contacts aceita um leque de filtros combináveis e paginação por offset:
| Filtro | O que faz |
|---|---|
search | nome, telefone, e-mail ou documento |
tags + tags_match | chaves de tag; casa any (padrão) ou all |
groups | chaves de grupo de contato |
status | active · pending_validation · opted_out · blocked · unreachable |
city · state · country · zip · document | filtros do perfil |
has_email | só quem tem (true) ou não tem (false) e-mail |
instance_id | último número que falou com o contato |
last_activity_after / _before | janela de last_message_at (RFC3339) |
created_after / _before | janela de criação (RFC3339) |
sort | last_activity (padrão) · name · created |
limit / offset | paginação (limit padrão 200, máx. 500) |
# Leads quentes em POA, com e-mail, ativos, ordenados por atividade
curl -G https://api.bzapper.com.br/contacts \
-H "Authorization: Bearer $BZ_KEY" \
--data-urlencode "tags=lead-quente" \
--data-urlencode "city=Porto Alegre" \
--data-urlencode "has_email=true" \
--data-urlencode "status=active"
Resposta:
{ "data": [ { "id": "…", "phone": "+55…", "name": "…", "tags": ["lead-quente"] } ],
"total": 1, "limit": 200, "offset": 0 }
Importar em lote e exportar CSV
Para mover uma base inteira você não precisa de mil chamadas: POST /contacts/import
sobe até 1000 contatos por requisição e GET /contacts/export devolve a base em
CSV, com os mesmos filtros da listagem.
Importar — POST /contacts/import
Cada linha é casada pelo telefone (upsert): quem não existe é criado, quem já
existe é atualizado. Só phone é obrigatório; name, email, document,
document_type, address, tags[] e groups[] são opcionais.
curl -X POST https://api.bzapper.com.br/contacts/import \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{
"dry_run": true,
"contacts": [
{ "phone": "+5511999990000", "name": "Ana", "email": "[email protected]",
"tags": ["lead-quente"], "groups": ["onboarding"] },
{ "phone": "+5511888880000", "name": "Bruno",
"address": { "city": "Porto Alegre", "state": "RS", "country": "BR" } }
]
}'
Resposta 200 — o resultado linha a linha:
{
"dry_run": true,
"total": 2,
"created": 1,
"updated": 1,
"skipped": 0,
"failed": 0,
"skipped_rows": [],
"errors": []
}
No painel, a tela Contatos tem Importar (planilha → de-para de colunas → ensaio → gravar, fatiando sozinho os lotes acima de 1000 linhas) e Exportar CSV (baixa exatamente o que está filtrado na tela).
dry_run: true primeirodry_run valida tudo e não grava nada — você vê quantos contatos entrariam,
quantos seriam pulados e quais linhas estão erradas antes de tocar na base. Depois
repita a mesma chamada sem o campo.
As seis regras do import
| Regra | Por quê importa |
|---|---|
Contato novo nasce source: import e status: pending_validation | ele não entra em campanha assim. Chame POST /contacts/{id}/optin (o registro de consentimento) para promovê-lo a active |
| Campo em branco nunca apaga o que já existe | mandar "name": "" (ou omitir o campo) preserva o nome atual — o import enriquece, não zera |
| Suprimido / opt-out / bloqueado é pulado, nunca ressuscita | quem pediu descadastro não volta por planilha. É LGPD, e o import não é uma porta dos fundos |
| Tags e grupos são criados sob demanda | chave que não existe no dicionário é criada na hora — você não precisa pré-cadastrar em /tags e /contact-groups |
| Linha ruim não derruba o lote | a linha inválida vai para errors e o resto do lote é gravado normalmente |
| Teto de 1000 linhas por chamada | acima disso a API responde 422 import_too_large. Fatie a planilha em lotes |
Motivos (reason) de cada linha
skipped_rows e errors trazem { index, phone, reason, detail }, onde index é a
posição da linha no array que você enviou — dá para apontar o erro direto na planilha
do cliente.
reason em skipped_rows (pulado) | O que aconteceu |
|---|---|
duplicate_phone | o mesmo telefone aparece duas vezes no lote |
suppressed | está na lista de supressão do projeto |
opted_out | pediu descadastro (LGPD) |
blocked | bloqueado manualmente por um admin |
unreachable | marcado com problema de recebimento |
deleted | o contato foi removido da base |
reason em errors (falhou) | O que aconteceu |
|---|---|
phone_required | a linha veio sem phone |
invalid_phone | telefone fora do formato +DDIdigits |
invalid_email | e-mail inválido |
write_failed | falha ao gravar o contato |
taxonomy_failed | falha ao criar/correlacionar as tags ou os grupos |
Erros da chamada inteira: 400 invalid_body / contacts_required (corpo malformado ou
lista vazia) e 422 import_too_large (mais de 1000 linhas).
Exportar — GET /contacts/export
Devolve a base em CSV (text/csv, com Content-Disposition: attachment) —
transmitido, sem carregar tudo na memória. Aceita exatamente os mesmos filtros
de GET /contacts (sem offset; limit é o teto de linhas, máx. 100000).
# Só os leads quentes ativos de POA, com e-mail — direto para um arquivo
curl -G https://api.bzapper.com.br/contacts/export \
-H "Authorization: Bearer $BZ_KEY" \
--data-urlencode "tags=lead-quente" \
--data-urlencode "status=active" \
--data-urlencode "city=Porto Alegre" \
--data-urlencode "has_email=true" \
-o contatos.csv
As colunas são fixas, nesta ordem:
phone,name,email,status,source,tags,groups,created_at,last_activity_at
+5511999990000,Ana,[email protected],active,import,lead-quente;vip,onboarding,2026-07-01T12:00:00Z,2026-07-03T09:20:00Z
tagsegroupsvêm unidos por;(ponto e vírgula) dentro da célula — a vírgula é o separador do CSV.created_atelast_activity_atsão RFC 3339 em UTC.- É a única rota da API que não responde JSON: as SDKs devolvem o texto CSV cru.
O CSV traz telefone, e-mail e documento dos seus contatos. Trate o arquivo como dado pessoal (acesso restrito, descarte depois de usar) — veja Privacidade & LGPD.
Nas SDKs oficiais
Os dois endpoints têm método em todas as 7 linguagens. O exportContacts devolve
string (o CSV), não objeto.
// Node / TypeScript
const dry = await bz.importContacts({ contacts: rows, dry_run: true });
if (dry.failed === 0) await bz.importContacts({ contacts: rows });
const csv = await bz.exportContacts({ tags: ["lead-quente"], status: "active" });
# Python
dry = bz.import_contacts([{"phone": "+5511999990000", "name": "Ana"}], dry_run=True)
if dry["failed"] == 0:
bz.import_contacts([{"phone": "+5511999990000", "name": "Ana"}])
csv_text = bz.export_contacts(tags=["lead-quente"], status="active")
// PHP
$dry = $bz->importContacts([['phone' => '+5511999990000', 'name' => 'Ana']], true);
$csv = $bz->exportContacts(['tags' => ['lead-quente'], 'status' => 'active']);
// Go
res, err := bz.ImportContacts(ctx, bzapper.ImportContactsParams{
Contacts: []bzapper.ContactImportRow{{Phone: "+5511999990000", Name: "Ana"}},
DryRun: true,
})
csv, err := bz.ExportContacts(ctx, bzapper.ExportContactsParams{Tags: "lead-quente", Limit: 5000})
// Java
ContactImportResult dry = bz.importContacts(rows, true);
String csv = bz.exportContacts(Map.of("tags", List.of("lead-quente"), "status", "active"));
// .NET (C#)
var dry = await bz.ImportContactsAsync(new ContactImport {
Contacts = new List<ContactImportRow> { new() { Phone = "+5511999990000", Name = "Ana" } },
DryRun = true,
});
var csv = await bz.ExportContactsAsync(tags: new[] { "lead-quente" }, status: "active");
# Ruby
dry = bz.contacts.import_contacts(contacts: [{ "phone" => "+5511999990000", "name" => "Ana" }], dry_run: true)
csv = bz.contacts.export_contacts(tags: ["lead-quente"], status: "active")
Timeline (histórico)
GET /contacts/{id}/history devolve a linha do tempo unificada — mensagens
(entrada/saída) e eventos (opt-out, notas, mudanças de tag/grupo, transições de
status) — mais recente primeiro.
{
"data": [
{ "kind": "message", "type": "text", "direction": "inbound",
"status": "received", "actor": "contact", "payload": { "body": "oi" },
"created_at": "2026-07-01T12:03:00Z" },
{ "kind": "event", "type": "tag_added", "actor": "api",
"payload": { "tag": "lead-quente" }, "created_at": "2026-07-01T12:00:00Z" }
]
}
Notas internas
POST /contacts/{id}/notes adiciona uma nota interna (auditoria/CRM) — nunca
enviada ao contato; aparece na timeline.
curl -X POST https://api.bzapper.com.br/contacts/$ID/notes \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "body": "Cliente pediu retorno na sexta." }'
Tags e grupos de contato
Tags e grupos de contato são dicionários próprios do projeto — cada entrada tem
key (slug estável usado na correlação), name, color e a contagem de contatos.
# Criar uma tag
curl -X POST https://api.bzapper.com.br/tags \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "lead-quente", "name": "Lead quente", "color": "#22c55e" }'
# Criar um grupo de contato (segmento de CRM — NÃO grupo de WhatsApp)
curl -X POST https://api.bzapper.com.br/contact-groups \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "onboarding", "name": "Onboarding" }'
Listar (GET /tags, GET /contact-groups) devolve as entradas com count. Apagar
(DELETE /tags/{id}, DELETE /contact-groups/{id}) remove do dicionário e desvincula
de todos os contatos.
Correlacionar em lote num contato
# Adiciona/remove tags num contato de uma vez (chaves novas são criadas)
curl -X POST https://api.bzapper.com.br/contacts/$ID/tags \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "add": ["lead-quente"], "remove": ["frio"] }'
O mesmo formato ({ "add": [...], "remove": [...] }) vale para
POST /contacts/{id}/groups.
Opt-out, supressão e problemas de recebimento
O bloco de privacidade da base tem três ações no contato e uma lista de supressão do projeto:
| Rota | O que faz |
|---|---|
POST /contacts/{id}/optout | marca opted_out (grava opted_out_at) e adiciona à supressão — LGPD |
POST /contacts/{id}/suppress | bloqueio manual: status blocked + entrada de supressão |
POST /contacts/{id}/optin | remove a supressão e reativa (active) |
/contacts/{id}/suppress ≠ /contacts/{jid}/blocksuppress bloqueia o contato no bZapper (não recebe mais envios). Já
/contacts/{jid}/block bloqueia o número no WhatsApp (device-level). São camadas
distintas.
Lista de supressão do projeto
# Listar números suprimidos
curl https://api.bzapper.com.br/suppressions \
-H "Authorization: Bearer $BZ_KEY"
# Adicionar manualmente
curl -X POST https://api.bzapper.com.br/suppressions \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+5511999998888", "reason": "manual" }'
# Remover (un-suppress) pelo telefone
curl -X DELETE "https://api.bzapper.com.br/suppressions?phone=%2B5511999998888" \
-H "Authorization: Bearer $BZ_KEY"
Cada entrada guarda phone, reason (ex.: optout, manual, bounce) e source
(api, admin, contact, system). Para entender a supressão de dois níveis e o
opt-out por palavra-chave, veja Privacidade & LGPD.
Endpoints
| Método | Rota | O que faz |
|---|---|---|
| GET | /contacts | Lista com filtros avançados + paginação |
| POST | /contacts | Cria um contato (phone obrigatório) |
| POST | /contacts/import | Importa/atualiza até 1000 contatos por telefone (dry_run) |
| GET | /contacts/export | Exporta a base em CSV (mesmos filtros da listagem) |
| GET | /contacts/{id} | Detalha um contato |
| PATCH | /contacts/{id} | Atualização parcial dos campos de CRM |
| DELETE | /contacts/{id} | Remove o contato |
| GET | /contacts/{id}/history | Timeline (mensagens + eventos) |
| POST | /contacts/{id}/notes | Adiciona nota interna |
| POST | /contacts/{id}/tags | Correlaciona tags em lote (add/remove) |
| POST | /contacts/{id}/groups | Correlaciona grupos de contato em lote |
| POST | /contacts/{id}/optout · /suppress · /optin | Opt-out / bloqueio / reativação |
| GET · POST | /tags · /contact-groups | Dicionários (com contagem) |
| DELETE | /tags/{id} · /contact-groups/{id} | Remove do dicionário e desvincula |
| GET · POST · DELETE | /suppressions | Lista de supressão do projeto |
Na maioria dos casos você nem chama o CRM diretamente: mande tags[]/groups[] no
envio e deixe o bZapper carimbar os contatos por você.