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 "X-Project-Id: $PROJECT" \
-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 "X-Project-Id: $PROJECT" \
-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": "active",
"source": "api",
"message_count": 0,
"tags": [],
"groups": [],
"created_at": "2026-07-01T12:00:00Z",
"updated_at": "2026-07-01T12:00:00Z"
}
Creating with a phone that already exists returns 409. The update is partial
(PATCH /contacts/{id}) — 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" -H "X-Project-Id: $PROJECT" \
--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 }
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 "X-Project-Id: $PROJECT" \
-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 "X-Project-Id: $PROJECT" \
-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 "X-Project-Id: $PROJECT" \
-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 "X-Project-Id: $PROJECT" \
-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" -H "X-Project-Id: $PROJECT"
# Add manually
curl -X POST https://api.bzapper.com.br/suppressions \
-H "Authorization: Bearer $BZ_KEY" -H "X-Project-Id: $PROJECT" \
-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" -H "X-Project-Id: $PROJECT"
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) |
| 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.