Pular para o conteúdo principal

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.

Contato ≠ grupo de WhatsApp

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_at e message_count — atividade viva.
  • source — como entrou na base: inbound, outbound, api, import ou widget.
  • name e avatar_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 WhatsApp

groups[] 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":

CampoO que é
phonetelefone +DDIdigits (obrigatório na criação)
namenome (push name do WhatsApp ou o que você informar)
emaile-mail (armazenado em minúsculo)
document / document_typedocumento (CPF/CNPJ/passaporte…) e seu tipo
addressendereço em campos separados: street, number, complement, district, city, state, zip, country
tags / groupschaves de tags e grupos de contato correlacionados
statusestado do ciclo de vida (veja abaixo)
instance_idúltimo número que conversou com ele
message_count, last_message_at, created_at, updated_atatividade

Estados (status)​

EstadoSignificado
activeativo, recebe normalmente
pending_validationainda não validado
opted_outpediu descadastro (LGPD) — bloqueado para envio
blockedbloqueado manualmente por um admin
unreachableproblema 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"
}
O contato nasce pending_validation

Contato 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:

FiltroO que faz
searchnome, telefone, e-mail ou documento
tags + tags_matchchaves de tag; casa any (padrão) ou all
groupschaves de grupo de contato
statusactive · pending_validation · opted_out · blocked · unreachable
city · state · country · zip · documentfiltros do perfil
has_emailsó quem tem (true) ou não tem (false) e-mail
instance_idúltimo número que falou com o contato
last_activity_after / _beforejanela de last_message_at (RFC3339)
created_after / _beforejanela de criação (RFC3339)
sortlast_activity (padrão) · name · created
limit / offsetpaginaçã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).

Rode sempre com dry_run: true primeiro

dry_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​

RegraPor quê importa
Contato novo nasce source: import e status: pending_validationele 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á existemandar "name": "" (ou omitir o campo) preserva o nome atual — o import enriquece, não zera
Suprimido / opt-out / bloqueado é pulado, nunca ressuscitaquem pediu descadastro não volta por planilha. É LGPD, e o import não é uma porta dos fundos
Tags e grupos são criados sob demandachave 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 lotea linha inválida vai para errors e o resto do lote é gravado normalmente
Teto de 1000 linhas por chamadaacima 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_phoneo mesmo telefone aparece duas vezes no lote
suppressedestá na lista de supressão do projeto
opted_outpediu descadastro (LGPD)
blockedbloqueado manualmente por um admin
unreachablemarcado com problema de recebimento
deletedo contato foi removido da base
reason em errors (falhou)O que aconteceu
phone_requireda linha veio sem phone
invalid_phonetelefone fora do formato +DDIdigits
invalid_emaile-mail inválido
write_failedfalha ao gravar o contato
taxonomy_failedfalha 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
  • tags e groups vêm unidos por ; (ponto e vírgula) dentro da célula — a vírgula é o separador do CSV.
  • created_at e last_activity_at são RFC 3339 em UTC.
  • É a única rota da API que não responde JSON: as SDKs devolvem o texto CSV cru.
Exportar é um caminho de saída de dados pessoais

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:

RotaO que faz
POST /contacts/{id}/optoutmarca opted_out (grava opted_out_at) e adiciona à supressão — LGPD
POST /contacts/{id}/suppressbloqueio manual: status blocked + entrada de supressão
POST /contacts/{id}/optinremove a supressão e reativa (active)
/contacts/{id}/suppress ≠ /contacts/{jid}/block

suppress 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étodoRotaO que faz
GET/contactsLista com filtros avançados + paginação
POST/contactsCria um contato (phone obrigatório)
POST/contacts/importImporta/atualiza até 1000 contatos por telefone (dry_run)
GET/contacts/exportExporta 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}/historyTimeline (mensagens + eventos)
POST/contacts/{id}/notesAdiciona nota interna
POST/contacts/{id}/tagsCorrelaciona tags em lote (add/remove)
POST/contacts/{id}/groupsCorrelaciona grupos de contato em lote
POST/contacts/{id}/optout · /suppress · /optinOpt-out / bloqueio / reativação
GET · POST/tags · /contact-groupsDicionários (com contagem)
DELETE/tags/{id} · /contact-groups/{id}Remove do dicionário e desvincula
GET · POST · DELETE/suppressionsLista de supressão do projeto
Correlação sem esforço

Na maioria dos casos você nem chama o CRM diretamente: mande tags[]/groups[] no envio e deixe o bZapper carimbar os contatos por você.