Contacts and consent from your system
sync contacts · upsert contact · import users · CRM sync · custom attributes · custom fields · consent API · unsubscribe API · topics API · batch contacts · externalId · contacts:write
Your product knows your users; Azimea needs to know them too, so flows and campaigns can reach the right people. This guide shows how to keep the two in step: who the person is, what you know about them, and what they agreed to receive.
All calls below use https://dashboard.azimea.com/api/v1 and an API key with the contacts:write (and, for reading,
contacts:read) permission, sent as Authorization: Bearer azm_live_….
Create or update one contact
Section titled “Create or update one contact”POST /contacts creates the person or updates them: there is no separate
“create” and “update”. Send your own user id as externalId so you never have to store Azimea’s id.
curl https://dashboard.azimea.com/api/v1/contacts \ -H "Authorization: Bearer $AZIMEA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalId": "user_4821", "email": "ana.popescu@example.com", "firstName": "Ana", "language": "ro", "attributes": { "plan": "pro", "seats": 12 } }'using System.Net.Http.Headers;using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://dashboard.azimea.com/api/v1/") };http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AZIMEA_API_KEY"));
var response = await http.PostAsJsonAsync("contacts", new{ externalId = "user_4821", email = "ana.popescu@example.com", firstName = "Ana", language = "ro", attributes = new { plan = "pro", seats = 12 }});response.EnsureSuccessStatusCode();const response = await fetch('https://dashboard.azimea.com/api/v1/contacts', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AZIMEA_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ externalId: 'user_4821', email: 'ana.popescu@example.com', firstName: 'Ana', language: 'ro', attributes: { plan: 'pro', seats: 12 }, }),});const { isSuccess, value, errors } = await response.json();$ch = curl_init('https://dashboard.azimea.com/api/v1/contacts');curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('AZIMEA_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'externalId' => 'user_4821', 'email' => 'ana.popescu@example.com', 'firstName' => 'Ana', 'language' => 'ro', 'attributes' => ['plan' => 'pro', 'seats' => 12], ]),]);$result = json_decode(curl_exec($ch), true);import os, requests
response = requests.post( "https://dashboard.azimea.com/api/v1/contacts", headers={"Authorization": f"Bearer {os.environ['AZIMEA_API_KEY']}"}, json={ "externalId": "user_4821", "email": "ana.popescu@example.com", "firstName": "Ana", "language": "ro", "attributes": {"plan": "pro", "seats": 12}, },)result = response.json()The answer carries the contact and created (true the first time). Sending the same contact again leaves it as the
first call did, so retries after a timeout are safe.
How the person is found
Section titled “How the person is found”- By
id(Azimea’s id), if you send one. - Otherwise by
externalId(your user id). - Otherwise by
email. - If none matches, the contact is created; it then needs an
email(audience.contact_email_required).
A user id and an email that point at two different contacts are refused, never merged: 409
audience.contact_identity_conflict. Fix the data on your side, then send again.
Fields you leave out keep their value. attributes is a patch: a key you send is set, a key sent as null is removed,
the others stay.
Contacts that come from a store
Section titled “Contacts that come from a store”If the person is also a customer of a connected shop, the shop owns their email, id and consent: a call that tries to
change them is refused with audience.contact_store_managed. One exception: an unsubscribe is always honoured,
whatever its source.
Read contacts
Section titled “Read contacts”| You want | Call |
|---|---|
| One person, by Azimea’s id | GET /contacts/{id} |
| One person, by your user id | GET /contacts/ext:user_4821 |
| One person, by email | GET /contacts/email:ana.popescu@example.com |
| Everyone, newest first, 1–200 per page | GET /contacts?page=1&pageSize=200 |
| The people of one segment | GET /contacts?segment={id}, with ids from GET /segments |
The ext: and email: forms find only contacts your own systems created (through the API or an import). A shop’s
customers are found by their Azimea id, which you get from GET /contacts or from a webhook.
Custom fields
Section titled “Custom fields”Anything you know about a person beyond name and email (a plan, a seat count, a renewal date) is a custom field. Define each one before sending it, so its type is fixed and a rule like “seats greater than 10” means the same thing in every segment:
curl https://dashboard.azimea.com/api/v1/contact-attributes \ -H "Authorization: Bearer $AZIMEA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "seats", "label": "Seats", "type": "Number" }'- Types:
Text,Number,Boolean,Date,DateTime,TextList. The key and the type never change afterwards. - Keys: lowercase letters, digits and
_, starting with a letter, up to 40 characters. A built-in field’s name is refused (audience.attribute_reserved); an existing key gives409audience.attribute_exists. - At most 100 fields per company (
audience.attribute_limit_reached). - Rename or archive with
PATCH /contact-attributes/{key}; archiving hides the field from pickers and keeps every stored value. - List with
GET /contact-attributes(archived fields included).
A contact sent with a key you never defined is refused with audience.attribute_unknown, unless Create new fields from
the API is on in Settings › Contact fields: then the field is created, with its type taken from the first value.
Leave it off in production, so a typo in your code never becomes a new field.
Consent
Section titled “Consent”Every contact has a consent status that decides what Azimea may send them. From your
system you set the basis in consent:
basis |
What it means | What you must send |
|---|---|---|
Customer |
The person is your customer (existing-customer rules apply). | Nothing more. |
Subscribed |
The person agreed to receive your emails. | proof.text: the exact words they agreed to. Without it: audience.consent_proof_required. |
Unsubscribed |
The person no longer wants marketing. | Nothing more. Service emails still reach them. |
{ "externalId": "user_4821", "email": "ana.popescu@example.com", "consent": { "basis": "Subscribed", "proof": { "text": "Send me product news and offers from Notely.", "textVersion": "2", "locale": "ro_RO", "ipAddress": "203.0.113.7", "givenAt": "2026-10-05T09:30:00Z" } }}The proof is kept in the person’s consent history, which is what you show if someone asks how you got their consent.
Rules worth knowing:
- Consent only moves up. Sending
Customerfor a subscriber changes nothing. - Resubscribing someone who unsubscribed needs
Subscribedwith proof: a new agreement, not a status change. - A bounce or a spam complaint is never lifted from the API.
- Leaving consent out leaves it as it is.
What each status allows is in Consent and GDPR.
Topics
Section titled “Topics”If your company sends several kinds of email (product news, tips, offers), people can turn each topic on or off. Read
the keys with GET /topics, then send the person’s choices:
{ "externalId": "user_4821", "topics": { "product_news": true, "offers": false } }An unknown or archived topic key is refused (topics.not_found, topics.archived).
Many contacts at once
Section titled “Many contacts at once”POST /contacts/batch takes up to 1,000 contacts, each written
exactly as POST /contacts would write it. One invalid contact does not stop the others: the answer lists every
contact in the order you sent them, with Created, Updated or Failed, its id or its errors, and the totals. More
than 1,000 is refused with audience.batch_too_large. A batch counts as one call for the rate limit.
{ "created": 1, "updated": 0, "failed": 1, "results": [ { "index": 0, "status": "Created", "id": "01a10a3c-5e2f-7b41-9c0d-4f7a2b6e1d90", "errors": null }, { "index": 1, "status": "Failed", "id": null, "errors": [{ "code": "validation.email", "field": "Email", "params": null }] } ]}A sync pattern that works
Section titled “A sync pattern that works”- Once: send your existing users with
POST /contacts/batch, 1,000 at a time. - In real time: call
POST /contactswhen a user signs up or changes their profile, and when they change their email preferences in your product (sendconsentandtopics). - Nightly (optional): send the users changed that day in batches, to catch anything a real-time call missed. Because writes are idempotent, sending someone twice is harmless.
Plan limits
Section titled “Plan limits”A new contact counts toward your plan. When the plan is full, a new contact is refused with
audience.contact_limit_reached; updates to existing contacts keep working.
Erasing a person (GDPR)
Section titled “Erasing a person (GDPR)”When someone asks you to erase their data, erase them in Azimea too with
DELETE /contacts/{reference}: the same erasure as the dashboard’s. Their
name, address, custom fields, tags, consent history and activity are removed, a single anonymous row stays so a later
import does not bring them back, and the rest of Azimea erases its part (timeline, flow runs, messages, orders, reports).
It cannot be undone, and it is recorded in your audit log.
The call needs the contacts:erase permission, which no other permission includes: give it only to the key of the
system that handles erasure requests, not to the one that keeps contacts in sync.
curl -X DELETE "https://dashboard.azimea.com/api/v1/contacts/email:ana@example.com" \ -H "Authorization: Bearer $AZIMEA_API_KEY"The answer is the erased contact’s id. email: and ext: find only contacts your system or an import created; a store’s
customers are erased by their Azimea id. A contact already erased answers 404 audience.contact_not_found: there is
nothing left to erase. To stop marketing without erasing anyone, send "consent": { "basis": "Unsubscribed" } instead.