Skip to content

Contacts and consent from your system

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

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.

Terminal window
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 }
}'

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.

  1. By id (Azimea’s id), if you send one.
  2. Otherwise by externalId (your user id).
  3. Otherwise by email.
  4. 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.

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.

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.

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:

Terminal window
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 gives 409 audience.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.

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 Customer for a subscriber changes nothing.
  • Resubscribing someone who unsubscribed needs Subscribed with 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.

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

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 }] }
]
}
  1. Once: send your existing users with POST /contacts/batch, 1,000 at a time.
  2. In real time: call POST /contacts when a user signs up or changes their profile, and when they change their email preferences in your product (send consent and topics).
  3. 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.

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.

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.

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