Contacts and consent
Every number that talks to your account becomes a contact. The API lets your system — a CRM, a store, an ERP — keep that base in sync: create and update contacts, store fields of your own, organize them with tags and, above all, record who agreed to receive marketing. That record is what decides who goes into a campaign.
Create or update a contact
curl -X POST 'https://api.joinotify.com/contacts?upsert=true' \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"phone": "+55 41 98711-1527",
"firstName": "Ana",
"email": "[email protected]",
"attributes": { "plan": "Pro" }
}'
The phone is normalized to E.164 and stored without the + (5541987111527). Without
a country code, send defaultCountry: "BR". Brazilian mobile numbers with and without the
ninth digit are recognized as the same contact.
Without ?upsert=true, a phone that already exists answers 409 contact_exists with its
contactId. With it, the existing contact is updated and the answer is 200.
Syncing in bulk
For a large base, POST /contacts/batch takes up to 500 contacts per call and returns the
outcome of each row, in the order they were sent:
{
"contacts": [
{ "phone": "+5541987111527", "name": "Ana Souza", "tags": ["customer"] },
{ "phone": "+5511912345678", "firstName": "Bruno", "attributes": { "city": "São Paulo" } }
],
"options": { "onDuplicate": "update", "defaultCountry": "BR" }
}
The answer is 200 even when rows fail — check results[].status. Rows with failed
carry a reason and, when possible, the field at fault. A phone repeated within the
same batch becomes a single contact.
On a batch update, only filled values overwrite and tags are added, never removed. For
an import of thousands of rows across several batches, first open a record with
POST /contacts/imports and send its id as options.importId: the counters add up
there, and the dashboard shows the progress.
contact.created and contact.opted_in go out on individual creation, not on a batch — a
ten-thousand-row import does not become ten thousand calls to your endpoint.
Custom fields and tags
Custom fields hold what is yours: the plan bought, the city, the date of the last
purchase. Create the field once with POST /contacts/fields and write the value in
attributes, by its key. The value is validated by type — a date field takes
2026-09-19 or 19/09/2026, a boolean takes yes/no, a select only takes the
registered options.
Tags are free labels for segmenting. On an individual edit, tagIds is the complete
set; to add or remove a tag on many contacts, use POST /contacts/tags/apply with a list
of ids or with the same filters as the listing.
Marketing consent
Marketing campaigns only go to whoever agreed to receive them. Each contact has an
optInStatus: opted_in, opted_out or unknown.
Record the agreement together with the evidence of how it was obtained — it is what you will need to show if anyone asks:
curl -X POST https://api.joinotify.com/contacts/cm1c0ntact0001/opt-in \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "evidence": "Checkbox ticked at checkout of order 1042" }'
Or right at creation, with "optIn": { "evidence": "..." }.
An opt-out happens through several paths, and not all of them go through your
integration: the person sends an opt-out word, Meta refuses a send with error 131050,
someone records it in the dashboard, or your integration calls
POST /contacts/{id}/opt-out. To hear about all of them, subscribe to the
contact.opted_out event.
No automatic path — batch, upsert, merging duplicates — turns an opt-out into an opt-in.
Only a new POST /contacts/{id}/opt-in, with evidence, does that.
Suppression list
The suppression list is what enforces the opt-out: phones and BSUIDs on it never
receive a marketing campaign, whatever the contact's consent. An opt-out puts the contact
on the list automatically, with the opt_out reason; you can add others by hand with
POST /suppressions.
| Reason | Where it comes from |
|---|---|
opt_out | The contact left |
meta_marketing_block | Meta is blocking marketing to that number |
invalid_number | The number has no WhatsApp |
manual | Someone added it in the dashboard or with POST /suppressions |
Removing an opt_out or meta_marketing_block row from the list requires confirm=true
— and it does not change consent: the contact stays opted out until a new opt-in.
Privacy
DELETE /contacts/{id}deletes the contact for good, with conversations and history — the data-protection erasure request. The suppression list row stays, without the identity.GET /contacts/{id}/exportreturns everything the account holds about the person, as a JSON — the access request.GET /contacts/exportproduces a CSV of the whole base, or of whatever the filters select.
An API key limited to some numbers only sees the contacts that have a conversation on
those numbers: the list only brings them, and a contact outside that slice answers 404,
like one that does not exist. With it you can read, edit fields and tags and record an
opt-out.
What covers the whole base answers 403: export, import, create, batch, delete, merge,
opt-in, the definitions of fields and tags and the suppression list. For those, use an
unrestricted key.