Skip to main content

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.

Contact ≠ WhatsApp group

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_at and message_count — live activity.
  • source — how it entered the base: inbound, outbound, api, import, or widget.
  • name and avatar_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 group

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

FieldWhat it is
phonephone +DDIdigits (required on creation)
namename (WhatsApp push name or the one you provide)
emailemail (stored lowercase)
document / document_typedocument (CPF/CNPJ/passport…) and its type
addressaddress in separate fields: street, number, complement, district, city, state, zip, country
tags / groupscorrelated tag and contact-group keys
statuslifecycle state (see below)
instance_idlast number that talked to it
message_count, last_message_at, created_at, updated_atactivity

States (status)​

StateMeaning
activeactive, receives normally
pending_validationnot validated yet
opted_outopted out (LGPD) — blocked for sends
blockedmanually blocked by an admin
unreachabledelivery 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"
}
A new contact starts as pending_validation

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

FilterWhat it does
searchname, phone, email or document
tags + tags_matchtag keys; matches any (default) or all
groupscontact-group keys
statusactive · pending_validation · opted_out · blocked · unreachable
city · state · country · zip · documentprofile filters
has_emailonly those with (true) or without (false) an email
instance_idlast number that talked to the contact
last_activity_after / _beforelast_message_at window (RFC3339)
created_after / _beforecreation window (RFC3339)
sortlast_activity (default) · name · created
limit / offsetpagination (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).

Always run with dry_run: true first

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

RuleWhy it matters
A new contact starts as source: import and status: pending_validationit 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 datasending "name": "" (or omitting the field) keeps the current name — import enriches, it does not wipe
Suppressed / opted-out / blocked is skipped, never resurrectedwhoever 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 demanda 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 downthe invalid row goes to errors and the rest of the batch is written normally
A cap of 1000 rows per callbeyond 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_phonethe same phone appears twice in the batch
suppressedit is on the project's suppression list
opted_outit opted out (LGPD)
blockedmanually blocked by an admin
unreachableflagged with a delivery problem
deletedthe contact was removed from the base
reason in errors (failed)What happened
phone_requiredthe row came without phone
invalid_phonephone outside the +DDIdigits format
invalid_emailinvalid email
write_failedfailed to write the contact
taxonomy_failedfailed 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
  • tags and groups come ;-joined (semicolon) inside the cell — the comma is the CSV separator.
  • created_at and last_activity_at are 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.
Export is a personal-data egress path

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:

RouteWhat it does
POST /contacts/{id}/optoutmarks opted_out (writes opted_out_at) and adds to suppression — LGPD
POST /contacts/{id}/suppressmanual block: status blocked + suppression entry
POST /contacts/{id}/optinremoves the suppression and reactivates (active)
/contacts/{id}/suppress ≠ /contacts/{jid}/block

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

MethodRouteWhat it does
GET/contactsList with advanced filters + pagination
POST/contactsCreate a contact (phone required)
POST/contacts/importImport/update up to 1000 contacts by phone (dry_run)
GET/contacts/exportExport 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}/historyTimeline (messages + events)
POST/contacts/{id}/notesAdd an internal note
POST/contacts/{id}/tagsBatch tag correlation (add/remove)
POST/contacts/{id}/groupsBatch contact-group correlation
POST/contacts/{id}/optout · /suppress · /optinOpt-out / block / reactivate
GET · POST/tags · /contact-groupsDictionaries (with counts)
DELETE/tags/{id} · /contact-groups/{id}Remove from dictionary and unlink
GET · POST · DELETE/suppressionsThe project's suppression list
Effortless correlation

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.