Skip to content

Contacts API

POST https://api.nexacloser.com/v1/contacts creates a CRM contact, or updates the one that already exists. One call, no lookup first. Full schema: API reference.

A key with Full access or Contacts: create & update only, sent as Authorization: Bearer nxa_…. Call it from your server. See Access levels.

Terminal window
curl -X POST https://api.nexacloser.com/v1/contacts \
-H "Authorization: Bearer $NEXACLOSER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"phone": "+14155550101",
"first_name": "Dana",
"last_name": "Levi",
"source": "Website contact form",
"tags": ["website", "consult-request"],
"custom_fields": { "visit_type": "first_visit", "contact_time": "morning" },
"attribution": { "utm_source": "google", "utm_medium": "cpc", "landing_url": "https://www.example.com/contact" },
"consent": {
"granted": true,
"channels": ["email", "sms"],
"text": "I agree to receive emails and text messages from Example Clinic. Reply STOP to opt out.",
"version": "2026-11-01",
"at": "2026-11-05T14:03:11Z",
"ip": "203.0.113.24",
"page_url": "https://www.example.com/contact"
}
}'
Field Type Notes
email string At least one of email and phone is required. Stored lowercase.
phone string E.164 is best (+14155550101). Without a country code, your workspace’s country is assumed. Must come out as 9 to 15 digits.
full_name string Or send first_name and last_name.
first_name, last_name string
source string, up to 100 A label for where the contact came from, e.g. Website contact form. Saved as the contact’s source channel, only when the contact is created; an existing contact keeps its own.
tags string[], up to 20, each up to 50 Added to the contact’s tags. Never removes a tag.
custom_fields object, up to 50 keys field_key → string, number, boolean or null. Keys match ^[a-z0-9_]{1,64}$. Merged into existing values.
preferred_language he, en or ru Language for messages to this contact. Omit to use the workspace default.
attribution object Click and campaign data for this visit. See Attribution.
consent object What the person agreed to. See Consent.

The body may be up to 64 KB. A field the endpoint does not know is ignored and listed in warnings as unknown_field:<name>, so a typo never fails silently.

Every contact created through the API gets the CRM source API. Your source label shows as its source channel, so you can tell your website from a partner.

Send what you know about the visit that produced the lead. The first touch is kept forever; the last touch is updated on every call. Values are trimmed and may be up to 2,048 characters.

Accepted keys: source_channel, utm_source, utm_medium, utm_campaign, utm_content, utm_term, utm_keyword, utm_matchtype, campaign_id, campaign_name, adset_id, adset_name, ad_id, ad_name, gclid, wbraid, gbraid, fbclid, fbc, fbp, ctwa_clid, msclkid, ttclid, referrer, landing_url. Any other key is ignored and listed in warnings as unknown_attribution_key:<key>.

Use the Key of a custom field, the English key you set when you created the field in the CRM, for example visit_type. A key with no field defined in your workspace is still saved on the contact, and the response lists it in warnings as unknown_custom_field:visit_type, so you can spot typos. Create the field in the app to see it on the contact card.

NexaCloser looks inside your workspace, among contacts that are not deleted:

  1. Same phone number? That contact is updated.
  2. Else same email? That contact is updated.
  3. Else a new contact is created.

On an update, values you send overwrite, empty values keep what is there, tags are added and custom fields are merged. The source channel and the first-touch attribution never change.

If your workspace turned on allow duplicate contacts, every call creates a new contact.

Automations run as if the contact came in any other way: a new contact fires your contact created automations, and tags it did not have before fire tag added automations.

201 when a contact was created, 200 when one was updated:

{
"id": "cnt_8hT2kQx91L",
"created": false,
"matched_by": "email",
"consent": [
{ "channel": "email", "status": "granted" },
{ "channel": "sms", "status": "withdrawn", "skipped": true }
],
"warnings": []
}
Field Meaning
id The contact id. Store it if you want to link back to the contact.
created true = new contact. false = an existing one was updated.
matched_by phone, email, or null when created.
consent One entry per channel when you sent consent with granted: true; empty otherwise.
warnings Notes that did not stop the request.

The response never contains other data of the contact, so a Contacts: create & update only key cannot be used to read your CRM.

Send consent when the person ticked an opt-in box on your form. NexaCloser keeps it as proof.

Field Notes
granted Required, boolean. true = they opted in. false = nothing is recorded.
channels email, sms, whatsapp. Default: email if you sent an email, sms and whatsapp if you sent a phone.
purpose marketing (default), transactional, service_updates or surveys.
text The exact wording the person saw. Required when granted is true. Up to 2,000 characters.
version Your label for that wording, up to 50 characters, e.g. 2026-11-01. Change it whenever the wording changes.
language he, en or ru: the language of the wording. Default: the contact’s preferred_language, else en.
at When they agreed, ISO 8601. Default: the time of the request. At most 5 minutes in the future.
ip The person’s IP address (IPv4 or IPv6), not your server’s.
user_agent The person’s browser.
page_url The page with the form.

For each channel NexaCloser stores the consent, and adds an append-only audit record with the wording, version, time, page and the name of the key that sent it. Marketing sends check the stored consent before they go out.

An unticked box is not an opt-out. granted: false stores nothing and changes nothing. Opting out is the person’s own action: an unsubscribe link, replying STOP, or a change in the CRM.

An opt-out always wins. If the person opted out of a channel earlier, a new granted: true from the API does not turn it back on. That channel comes back as { "channel": "sms", "status": "withdrawn", "skipped": true }. The person has to opt in again themselves.

If one channel cannot be stored it comes back as { "channel": "whatsapp", "status": "error" }. The contact itself is still saved; send the request again to retry the consent.

With a test key (nxa_test_…) the same request is fully validated, nothing is written and no contact is read. (It does read your workspace’s country and custom field list, to validate the way a live key would.) Valid requests get 200 with what a live key would store:

{
"test": true,
"valid": true,
"would": "create_or_update",
"normalized": {
"email": "[email protected]",
"phone": "14155550101",
"full_name": "Dana Levi",
"source": "Website contact form",
"tags": ["website", "consult-request"],
"custom_fields": { "visit_type": "first_visit", "contact_time": "morning" },
"preferred_language": null,
"attribution": { "utm_source": "google", "utm_medium": "cpc", "landing_url": "https://www.example.com/contact" },
"consent": {
"granted": true,
"channels": ["email", "sms"],
"purpose": "marketing",
"text": "I agree to receive emails and text messages from Example Clinic. Reply STOP to opt out.",
"version": "2026-11-01",
"at": "2026-11-05T14:03:11.000Z",
"language": "en",
"ip": "203.0.113.24",
"user_agent": null,
"page_url": "https://www.example.com/contact"
}
},
"warnings": ["unknown_custom_field:visit_type", "unknown_custom_field:contact_time"]
}

This is a real response to the request at the top of this page, from a workspace that has not created the visit_type and contact_time fields yet. phone comes back as digits with the country code, the way it will be stored.

Status error What to do
400 invalid_json The body is not valid JSON.
401 unauthorized Missing, wrong, revoked or rotated key, or the key’s workspace cannot be found.
403 forbidden The key’s access level does not include this endpoint.
405 method_not_allowed Use POST.
413 payload_too_large Keep the body under 64 KB.
422 validation_failed Fix the fields listed in details. Do not retry unchanged.
429 rate_limited Wait Retry-After seconds.
500 internal Retry with backoff. Repeating the call updates the same contact instead of adding one (unless your workspace allows duplicate contacts).

A 422 lists every problem at once:

{
"error": "validation_failed",
"details": [
{ "field": "email", "code": "invalid_email" },
{ "field": "custom_fields.visit_type", "code": "invalid_custom_field_value" },
{ "field": "consent.text", "code": "consent_text_required" }
]
}
code Meaning
invalid_body The body is JSON but not an object (for example an array).
invalid_type A field has the wrong JSON type (for example a number where a string belongs).
email_or_phone_required Send at least one of them.
invalid_email Not a valid email address.
invalid_phone Not a phone number of 9 to 15 digits once read with your workspace’s country.
invalid_language preferred_language or consent.language is not he, en or ru.
too_many_tags More than 20 tags.
invalid_tag A tag is not a non-empty string of up to 50 characters.
too_many_custom_fields More than 50 custom fields.
invalid_custom_field_key A key does not match ^[a-z0-9_]{1,64}$.
invalid_custom_field_value A value is an object or an array.
consent_granted_required consent was sent without a boolean granted.
consent_text_required consent.granted is true but consent.text is missing.
invalid_consent_channel A channel other than email, sms, whatsapp.
invalid_consent_purpose A purpose other than the four listed.
invalid_consent_time consent.at is not an ISO 8601 time, or is more than 5 minutes in the future.
invalid_consent_ip consent.ip is not an IPv4 or IPv6 address.
field_too_long A text field is over its limit.

100 requests per 10 seconds and 200,000 per day, per key. See Rate limits.