Skip to content

Create or update a contact

POST
/contacts
curl --request POST \
--url https://api.nexacloser.com/v1/contacts \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "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", "utm_campaign": "fall-promo", "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" } }'

Creates a CRM contact, or updates the matching one.

Matching. Inside your workspace, among live (not deleted) contacts, NexaCloser looks for the same phone number first, then the same email. A match is updated: non-empty values you send overwrite, empty values keep what is there, tags are added to the existing tags and custom_fields are merged into the existing values. No match: a new contact is created. If the workspace turned on allow duplicate contacts, every call creates a new contact.

Source. Every contact created through the API gets the CRM source api. The source label you send (for example Website contact form) is saved as the contact’s source channel, only when the contact is created; an existing contact keeps its own.

Automations. Creating a contact fires the workspace’s contact created automations. New tags fire tag added automations.

Consent. consent.granted: true records an opt-in per channel. granted: false records nothing: an unticked box is not an opt-out. A person who opted out earlier stays opted out.

What comes back. Only the contact id, whether it was created, how it was matched, the consent outcome per channel and warnings. Other data of an existing contact is never returned, so a contacts-only key can write but cannot read your CRM.

Warnings. Fields the endpoint does not know are ignored and listed in warnings (unknown_field:<name>, unknown_attribution_key:<key>), as are custom field keys with no field defined in the workspace (unknown_custom_field:<key>, still saved).

Test keys (nxa_test_…) validate the request exactly like a live key but write nothing and read no contact: the response is 200 with test: true and the normalized values.

Access. Works with a full-access key or a Contacts: create & update only key. Call it from your server; the only browser origin accepted is the try-it console on docs.nexacloser.com.

Media typeapplication/json
Any of:

Send email, phone or both.

object
email
required

The contact’s email. Stored lowercase.

string format: email
phone

The contact’s phone. E.164 (+14155550101) is best; a number without a country code is read with the workspace’s country. Must come out as 9 to 15 digits.

string
full_name

Full name. Or send first_name and last_name.

string
first_name
string
last_name
string
source

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. (The CRM source of an API-created contact is always api.)

string
<= 100 characters
tags

Added to the contact’s tags. Never removes a tag.

Array<string>
<= 20 items
custom_fields

Field key → value. Keys match ^[a-z0-9_]{1,64}$; values are string, number, boolean or null. Merged into the contact’s existing values. A key with no field defined in the workspace is still saved and is listed in warnings as unknown_custom_field:<key>.

object
<= 50 properties
key
additional properties
string | number | boolean | null
preferred_language

Language for messages to this contact. Omit to use the workspace default.

string
Allowed values: he en ru
attribution

Where this visit came from. The first touch is kept forever; the last touch is updated on every call. Values are trimmed, up to 2,048 characters. Any other key is ignored and listed in warnings as unknown_attribution_key:<key>.

object
source_channel
string
<= 2048 characters
utm_source
string
<= 2048 characters
utm_medium
string
<= 2048 characters
utm_campaign
string
<= 2048 characters
utm_content
string
<= 2048 characters
utm_term
string
<= 2048 characters
utm_keyword
string
<= 2048 characters
utm_matchtype
string
<= 2048 characters
campaign_id
string
<= 2048 characters
campaign_name
string
<= 2048 characters
adset_id
string
<= 2048 characters
adset_name
string
<= 2048 characters
ad_id
string
<= 2048 characters
ad_name
string
<= 2048 characters
gclid
string
<= 2048 characters
wbraid
string
<= 2048 characters
gbraid
string
<= 2048 characters
fbclid
string
<= 2048 characters
fbc
string
<= 2048 characters
fbp
string
<= 2048 characters
ctwa_clid
string
<= 2048 characters
msclkid
string
<= 2048 characters
ttclid
string
<= 2048 characters
referrer
string
<= 2048 characters
landing_url
string
<= 2048 characters
consent

What the person agreed to. Stored per channel, plus an append-only audit record with the wording, its version, the time, the page and the key that sent it. A person who opted out of a channel earlier stays opted out: that channel comes back withdrawn with skipped: true.

object
granted
required

true = the person opted in; one record per channel is stored. false = nothing is recorded (an unticked box is not an opt-out).

boolean
channels

Default email when you sent an email, plus sms and whatsapp when you sent a phone.

Array<string>
Allowed values: email sms whatsapp
purpose
string
default: marketing
Allowed values: marketing transactional service_updates surveys
text

The exact wording the person saw. Required when granted is true.

string
<= 2000 characters
version

Your version label for that wording, e.g. 2026-11-01.

string
<= 50 characters
language

Language of the wording. Default is the contact’s preferred_language, else en.

string
Allowed values: he en ru
at

When the person agreed. Default is the time of the request. May not be more than 5 minutes in the future.

string format: date-time
ip

The person’s IP address (not your server’s). IPv4 or IPv6.

string
user_agent

The person’s browser user agent.

string
page_url

The page where they agreed.

string

Examples

Website form with marketing consent

{
"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",
"utm_campaign": "fall-promo",
"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"
}
}

An existing contact was updated, or (test key) the request is valid and nothing was written.

Media typeapplication/json
One of:
object
id
required

The contact id.

string
created
required

true = a new contact; false = an existing one was updated.

boolean
matched_by
required

How the existing contact was found; null when created.

string | null
Allowed values: phone email
consent
required

One entry per channel when you sent consent; empty otherwise.

Array<object>
object
channel
required
string
Allowed values: email sms whatsapp
status
required

granted = stored. withdrawn = an earlier opt-out was kept (skipped: true). error = this channel could not be stored; the contact itself was saved.

string
Allowed values: granted withdrawn error
skipped

true when an earlier opt-out was kept and your consent was not applied.

boolean
warnings
required

Non-blocking notes, e.g. unknown_custom_field:visit_type.

Array<string>

Examples

Matched by email and updated

{
"id": "cnt_8hT2kQx91L",
"created": false,
"matched_by": "email",
"consent": [
{
"channel": "email",
"status": "granted"
},
{
"channel": "sms",
"status": "withdrawn",
"skipped": true
}
],
"warnings": [
"unknown_custom_field:contact_time"
]
}
X-RateLimit-Limit
integer

Requests allowed in the current short window.

X-RateLimit-Remaining
integer

Requests left in the current short window.

A new contact was created.

Media typeapplication/json
object
id
required

The contact id.

string
created
required

true = a new contact; false = an existing one was updated.

boolean
matched_by
required

How the existing contact was found; null when created.

string | null
Allowed values: phone email
consent
required

One entry per channel when you sent consent; empty otherwise.

Array<object>
object
channel
required
string
Allowed values: email sms whatsapp
status
required

granted = stored. withdrawn = an earlier opt-out was kept (skipped: true). error = this channel could not be stored; the contact itself was saved.

string
Allowed values: granted withdrawn error
skipped

true when an earlier opt-out was kept and your consent was not applied.

boolean
warnings
required

Non-blocking notes, e.g. unknown_custom_field:visit_type.

Array<string>

Example

{
"id": "cnt_8hT2kQx91L",
"created": true,
"matched_by": null,
"consent": [
{
"channel": "email",
"status": "granted"
},
{
"channel": "sms",
"status": "granted"
}
],
"warnings": []
}
X-RateLimit-Limit
integer

Requests allowed in the current short window.

X-RateLimit-Remaining
integer

Requests left in the current short window.

The body is not valid JSON.

Media typeapplication/json
object
error
required

A stable, machine-readable code.

string

Example

{
"error": "invalid_json"
}

Missing, wrong, revoked or rotated key, a key whose access level does not include this endpoint, or a key whose workspace cannot be resolved.

Media typeapplication/json
object
error
required

A stable, machine-readable code.

string

Example

{
"error": "unauthorized"
}

The key is valid but its access level does not include this endpoint.

Media typeapplication/json
object
error
required

A stable, machine-readable code.

string

Example

{
"error": "forbidden"
}

Only POST is accepted.

Media typeapplication/json
object
error
required

A stable, machine-readable code.

string

Example

{
"error": "method_not_allowed"
}

The body is larger than 64 KB.

Media typeapplication/json
object
error
required

A stable, machine-readable code.

string

Example

{
"error": "payload_too_large"
}

The body is valid JSON but a field is wrong. Every problem is listed.

Media typeapplication/json
object
error
required
string
Allowed value: validation_failed
details
required
Array<object>
object
field
required

The field, with dots for nested fields (e.g. consent.text, custom_fields.visit_type).

string
code
required
string
Allowed values: invalid_body invalid_type email_or_phone_required invalid_email invalid_phone invalid_language too_many_tags invalid_tag too_many_custom_fields invalid_custom_field_key invalid_custom_field_value consent_granted_required consent_text_required invalid_consent_channel invalid_consent_purpose invalid_consent_time invalid_consent_ip field_too_long

Example

{
"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"
}
]
}

Too many requests. Wait Retry-After seconds.

Media typeapplication/json
object
error
string
Allowed value: rate_limited
message
string
retry_after

Seconds to wait.

integer

Example

{
"error": "rate_limited",
"message": "Too many requests",
"retry_after": 7
}
Retry-After
integer

Seconds to wait before retrying.

Something failed on our side. Safe to retry with backoff.

Media typeapplication/json
object
error
required

A stable, machine-readable code.

string

Example

{
"error": "internal"
}