Contact management
Every message in or out becomes a contact in your base — a lightweight CRM embedded in the gateway. bZapper captures the contact automatically from the conversation (WhatsApp name, avatar, activity) and you enrich it with CRM fields (email, document, address), organize with tags and contact groups, filter by any criterion, and follow each person's timeline.
This page covers the contact base (people) and contact groups (CRM segments),
which live at /contacts and /contact-groups. Do not confuse them with WhatsApp
groups (chat rooms), which live at /groups. They are different things.
Automatic correlation
You don't need to register anything to get started: on every message exchanged, bZapper correlates the contact with the project and the number that talked to it. The contact gets:
instance_id— the last number that talked to it (useful for affinity/sticky).last_message_atandmessage_count— live activity.source— how it entered the base:inbound,outbound,api,import, orwidget.nameandavatar_url— display name (push name) and photo, best effort.
The phone is always normalized (+DDIdigits, no spaces) and the email is stored
lowercase — the same contact is recognized without duplicates.
Stamp tags and groups on a send
The groups[] and tags[] fields on any send stamp the recipient contact
when the message resolves — with no extra CRM call. Unknown keys are created in the
dictionary on the spot.
curl -X POST https://api.bzapper.com.br/messages/text \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511999998888",
"body": "Welcome! 🎉",
"tags": ["hot-lead"],
"groups": ["onboarding"]
}'
groups[] on a send does NOT send to a WhatsApp groupgroups[] correlates the contact with contact groups (CRM). To send to a WhatsApp
room, put the group JID in the to field.
Contact profile
Each contact has CRM fields in separate columns — no "loose notes":
| Field | What it is |
|---|---|
phone | phone +DDIdigits (required on creation) |
name | name (WhatsApp push name or the one you provide) |
email | email (stored lowercase) |
document / document_type | document (CPF/CNPJ/passport…) and its type |
address | address in separate fields: street, number, complement, district, city, state, zip, country |
tags / groups | correlated tag and contact-group keys |
status | lifecycle state (see below) |
instance_id | last number that talked to it |
message_count, last_message_at, created_at, updated_at | activity |
States (status)
| State | Meaning |
|---|---|
active | active, receives normally |
pending_validation | not validated yet |
opted_out | opted out (LGPD) — blocked for sends |
blocked | manually blocked by an admin |
unreachable | delivery problem (bounce/inferred) |
Create and update
# Create (phone required; the rest optional)
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" }
}'
Response 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_validationA hand-created contact does not start as active — it starts as
pending_validation, and nothing promotes it on its own. Since campaigns and bulk
selection only accept status = active, call POST /contacts/{id}/optin after
creating it (that is the consent record) so it can receive messages. A contact who has
already messaged you becomes active by itself.
Creating with a phone that already exists is not an error: it is an upsert. The
API answers 201 with the existing contact, filling in only the fields that were
empty — it does not overwrite a name, e-mail or document that is already set. To
change what is already there, use PATCH /contacts/{id} (partial update: send only
the fields that changed).
List with advanced filters
GET /contacts accepts a range of combinable filters and offset pagination:
| Filter | What it does |
|---|---|
search | name, phone, email or document |
tags + tags_match | tag keys; matches any (default) or all |
groups | contact-group keys |
status | active · pending_validation · opted_out · blocked · unreachable |
city · state · country · zip · document | profile filters |
has_email | only those with (true) or without (false) an email |
instance_id | last number that talked to the contact |
last_activity_after / _before | last_message_at window (RFC3339) |
created_after / _before | creation window (RFC3339) |
sort | last_activity (default) · name · created |
limit / offset | pagination (limit default 200, max 500) |
# Hot leads in Porto Alegre, with email, active, sorted by activity
curl -G https://api.bzapper.com.br/contacts \
-H "Authorization: Bearer $BZ_KEY" \
--data-urlencode "tags=hot-lead" \
--data-urlencode "city=Porto Alegre" \
--data-urlencode "has_email=true" \
--data-urlencode "status=active"
Response:
{ "data": [ { "id": "…", "phone": "+55…", "name": "…", "tags": ["hot-lead"] } ],
"total": 1, "limit": 200, "offset": 0 }
Bulk import and CSV export
Moving a whole base does not take a thousand calls: POST /contacts/import uploads up
to 1000 contacts per request, and GET /contacts/export streams the base back as
CSV, with the same filters as the list.
Import — POST /contacts/import
Each row is matched by phone (upsert): whoever does not exist is created, whoever
does is updated. Only phone is required; name, email, document,
document_type, address, tags[], and groups[] are optional.
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": ["hot-lead"], "groups": ["onboarding"] },
{ "phone": "+5511888880000", "name": "Bruno",
"address": { "city": "Porto Alegre", "state": "RS", "country": "BR" } }
]
}'
Response 200 — the per-row outcome:
{
"dry_run": true,
"total": 2,
"created": 1,
"updated": 1,
"skipped": 0,
"failed": 0,
"skipped_rows": [],
"errors": []
}
In the panel, the Contacts screen has Import (spreadsheet → column mapping → rehearsal → write, slicing batches above 1000 rows on its own) and Export CSV (downloads exactly what is filtered on screen).
dry_run: true firstdry_run validates everything and writes nothing — you see how many contacts
would come in, how many would be skipped, and which rows are wrong before touching
the base. Then repeat the same call without the field.
The six rules of import
| Rule | Why it matters |
|---|---|
A new contact starts as source: import and status: pending_validation | it does not enter a campaign like that. Call POST /contacts/{id}/optin (the consent record) to promote it to active |
| A blank field never erases existing data | sending "name": "" (or omitting the field) keeps the current name — import enriches, it does not wipe |
| Suppressed / opted-out / blocked is skipped, never resurrected | whoever asked to be removed does not come back through a spreadsheet. That is LGPD, and import is not a back door |
| Tags and groups are created on demand | a key that is not in the dictionary is created on the spot — you don't need to pre-register it in /tags and /contact-groups |
| A bad row does not bring the batch down | the invalid row goes to errors and the rest of the batch is written normally |
| A cap of 1000 rows per call | beyond that the API answers 422 import_too_large. Slice the spreadsheet into batches |
Per-row reasons (reason)
skipped_rows and errors carry { index, phone, reason, detail }, where index is
the position of the row in the array you sent — enough to point at the exact line of
your customer's spreadsheet.
reason in skipped_rows (skipped) | What happened |
|---|---|
duplicate_phone | the same phone appears twice in the batch |
suppressed | it is on the project's suppression list |
opted_out | it opted out (LGPD) |
blocked | manually blocked by an admin |
unreachable | flagged with a delivery problem |
deleted | the contact was removed from the base |
reason in errors (failed) | What happened |
|---|---|
phone_required | the row came without phone |
invalid_phone | phone outside the +DDIdigits format |
invalid_email | invalid email |
write_failed | failed to write the contact |
taxonomy_failed | failed to create/correlate the tags or groups |
Whole-call errors: 400 invalid_body / contacts_required (malformed body or empty
list) and 422 import_too_large (more than 1000 rows).
Export — GET /contacts/export
Returns the base as CSV (text/csv, with Content-Disposition: attachment) —
streamed, without loading everything into memory. It accepts exactly the same
filters as GET /contacts (minus offset; limit is the row cap, max 100000).
# Only active hot leads in Porto Alegre, with email — straight into a file
curl -G https://api.bzapper.com.br/contacts/export \
-H "Authorization: Bearer $BZ_KEY" \
--data-urlencode "tags=hot-lead" \
--data-urlencode "status=active" \
--data-urlencode "city=Porto Alegre" \
--data-urlencode "has_email=true" \
-o contacts.csv
The columns are fixed, in this order:
phone,name,email,status,source,tags,groups,created_at,last_activity_at
+5511999990000,Ana,[email protected],active,import,hot-lead;vip,onboarding,2026-07-01T12:00:00Z,2026-07-03T09:20:00Z
tagsandgroupscome;-joined (semicolon) inside the cell — the comma is the CSV separator.created_atandlast_activity_atare RFC 3339 in UTC.- It is the only route in the API that does not answer JSON: the SDKs hand you the raw CSV text.
The CSV carries your contacts' phone, email, and document. Treat the file as personal data (restricted access, dispose of it after use) — see Privacy & LGPD.
In the official SDKs
Both endpoints have a method in all 7 languages. exportContacts returns a
string (the CSV), not an object.
// 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: ["hot-lead"], 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=["hot-lead"], status="active")
// PHP
$dry = $bz->importContacts([['phone' => '+5511999990000', 'name' => 'Ana']], true);
$csv = $bz->exportContacts(['tags' => ['hot-lead'], '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: "hot-lead", Limit: 5000})
// Java
ContactImportResult dry = bz.importContacts(rows, true);
String csv = bz.exportContacts(Map.of("tags", List.of("hot-lead"), "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[] { "hot-lead" }, status: "active");
# Ruby
dry = bz.contacts.import_contacts(contacts: [{ "phone" => "+5511999990000", "name" => "Ana" }], dry_run: true)
csv = bz.contacts.export_contacts(tags: ["hot-lead"], status: "active")
Timeline (history)
GET /contacts/{id}/history returns the unified timeline — messages
(inbound/outbound) and events (opt-out, notes, tag/group changes, status
transitions) — most recent first.
{
"data": [
{ "kind": "message", "type": "text", "direction": "inbound",
"status": "received", "actor": "contact", "payload": { "body": "hi" },
"created_at": "2026-07-01T12:03:00Z" },
{ "kind": "event", "type": "tag_added", "actor": "api",
"payload": { "tag": "hot-lead" }, "created_at": "2026-07-01T12:00:00Z" }
]
}
Internal notes
POST /contacts/{id}/notes adds an internal note (audit/CRM) — never sent to
the contact; it shows up in the timeline.
curl -X POST https://api.bzapper.com.br/contacts/$ID/notes \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "body": "Customer asked for a follow-up on Friday." }'
Tags and contact groups
Tags and contact groups are the project's own dictionaries — each entry has a
key (stable slug used for correlation), name, color, and the contact count.
# Create a tag
curl -X POST https://api.bzapper.com.br/tags \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "hot-lead", "name": "Hot lead", "color": "#22c55e" }'
# Create a contact group (CRM segment — NOT a WhatsApp group)
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" }'
Listing (GET /tags, GET /contact-groups) returns entries with count. Deleting
(DELETE /tags/{id}, DELETE /contact-groups/{id}) removes them from the dictionary
and unlinks them from every contact.
Correlate in bulk on a contact
# Add/remove tags on a contact at once (new keys are created)
curl -X POST https://api.bzapper.com.br/contacts/$ID/tags \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "add": ["hot-lead"], "remove": ["cold"] }'
The same shape ({ "add": [...], "remove": [...] }) applies to
POST /contacts/{id}/groups.
Opt-out, suppression, and delivery problems
The base's privacy block has three actions on the contact plus a per-project suppression list:
| Route | What it does |
|---|---|
POST /contacts/{id}/optout | marks opted_out (writes opted_out_at) and adds to suppression — LGPD |
POST /contacts/{id}/suppress | manual block: status blocked + suppression entry |
POST /contacts/{id}/optin | removes the suppression and reactivates (active) |
/contacts/{id}/suppress ≠ /contacts/{jid}/blocksuppress blocks the contact in bZapper (no more sends). /contacts/{jid}/block
blocks the number on WhatsApp (device-level). They are distinct layers.
The project's suppression list
# List suppressed numbers
curl https://api.bzapper.com.br/suppressions \
-H "Authorization: Bearer $BZ_KEY"
# Add manually
curl -X POST https://api.bzapper.com.br/suppressions \
-H "Authorization: Bearer $BZ_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+5511999998888", "reason": "manual" }'
# Remove (un-suppress) by phone
curl -X DELETE "https://api.bzapper.com.br/suppressions?phone=%2B5511999998888" \
-H "Authorization: Bearer $BZ_KEY"
Each entry stores phone, reason (e.g. optout, manual, bounce), and source
(api, admin, contact, system). To understand two-level suppression and
keyword opt-out, see Privacy & LGPD.
Endpoints
| Method | Route | What it does |
|---|---|---|
| GET | /contacts | List with advanced filters + pagination |
| POST | /contacts | Create a contact (phone required) |
| POST | /contacts/import | Import/update up to 1000 contacts by phone (dry_run) |
| GET | /contacts/export | Export the base as CSV (same filters as the list) |
| GET | /contacts/{id} | Get a contact |
| PATCH | /contacts/{id} | Partial update of CRM fields |
| DELETE | /contacts/{id} | Remove the contact |
| GET | /contacts/{id}/history | Timeline (messages + events) |
| POST | /contacts/{id}/notes | Add an internal note |
| POST | /contacts/{id}/tags | Batch tag correlation (add/remove) |
| POST | /contacts/{id}/groups | Batch contact-group correlation |
| POST | /contacts/{id}/optout · /suppress · /optin | Opt-out / block / reactivate |
| GET · POST | /tags · /contact-groups | Dictionaries (with counts) |
| DELETE | /tags/{id} · /contact-groups/{id} | Remove from dictionary and unlink |
| GET · POST · DELETE | /suppressions | The project's suppression list |
Most of the time you don't even call the CRM directly: send tags[]/groups[] on the
message and let bZapper stamp the contacts for you.