Skip to main content

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.

A batch fires no webhooks

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

An opt-out does not undo itself

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.

ReasonWhere it comes from
opt_outThe contact left
meta_marketing_blockMeta is blocking marketing to that number
invalid_numberThe number has no WhatsApp
manualSomeone 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}/export returns everything the account holds about the person, as a JSON — the access request.
  • GET /contacts/export produces a CSV of the whole base, or of whatever the filters select.
A key limited to some numbers

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.