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.
Who can call it
Section titled “Who can call it”A key with Full access or Contacts: create & update only, sent as
Authorization: Bearer nxa_…. Call it from your server. See Access levels.
Request
Section titled “Request”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" } }'Fields
Section titled “Fields”| 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.
Attribution
Section titled “Attribution”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>.
Custom fields
Section titled “Custom fields”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.
What happens
Section titled “What happens”NexaCloser looks inside your workspace, among contacts that are not deleted:
- Same phone number? That contact is updated.
- Else same email? That contact is updated.
- 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.
Response
Section titled “Response”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.
Consent
Section titled “Consent”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.
Test keys
Section titled “Test keys”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": { "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.
Errors
Section titled “Errors”| 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. |
Limits
Section titled “Limits”100 requests per 10 seconds and 200,000 per day, per key. See Rate limits.
