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

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

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" -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

MethodRouteWhat it does
GET/contactsList with advanced filters + pagination
POST/contactsCreate 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}/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.