Upsert Contact
Create a contact, or update it if one already exists — in a single request.
Behaviour
match_on selects the key used to find an existing contact: external_id or
email. email is always required — it is the contact's identity and the data
used when a new contact is created.
- If no contact matches, a new one is created and the response is
201 Created. - If a contact matches, it is updated and the response is
200 OK.
The response body is the full contact in both cases; the status code tells you which branch ran. Repeating the same request converges on the same contact rather than creating duplicates.
Merge rules on the update branch
When an existing contact is matched, only the fields you include change:
- A field you include is set to the value you send.
- A field you omit is left unchanged.
- A nullable field sent as
null(or"") is cleared. custom_fieldsmerge: a field sent withvalue: nullis deleted; fields you omit are left unchanged.contact_listsare added; existing memberships are never removed.
email cannot change on the update branch. When you match by external_id and
send a different email, the matched contact keeps its existing email and the
email in the body is ignored.
Request
POST /contacts/upsert
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
match_on | string | Yes | Which key identifies an existing contact: external_id or email. |
email | string | Yes | Contact's email address (valid email format). Always required, including when matching by external_id. |
external_id | string|null | When match_on is external_id | External identifier (max 255 characters). Must be unique within your account. |
first_name | string|null | No | Contact's first name (max 255 characters). |
last_name | string|null | No | Contact's last name (max 255 characters). |
phone | string|null | No | Contact's phone number (max 32 characters). |
language | string|null | No | ISO 639-1 language code (2 characters, e.g. "en", "pl"). |
country_code | string|null | No | ISO 3166-1 alpha-2 country code (2 characters, e.g. "US", "PL"). |
timezone_uuid | string|null | No | UUID of the timezone to assign to the contact, from List Timezones. Follows the merge rules above on the update branch (omit to leave unchanged, null to clear). An unknown UUID returns 404. Read it back with Get Contact. |
contact_lists | array of strings | No | UUIDs of lists to add the contact to. Additive — listed lists are added, existing memberships are never removed. Each must reference an existing list. |
custom_fields | array | No | Custom field values to set (see Custom Fields section below). |
Example — match by external_id
Find the contact whose external_id is customer_123; create it if none exists.
curl -X POST "https://email.easy.tools/api/v1/contacts/upsert" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"match_on": "external_id",
"external_id": "customer_123",
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe",
"country_code": "US"
}'
Example — match by email
Find the contact whose email is john@example.com; create it if none exists.
When matching by email, you can also set external_id on the matched contact.
curl -X POST "https://email.easy.tools/api/v1/contacts/upsert" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"match_on": "email",
"email": "john@example.com",
"external_id": "customer_123",
"language": "pl",
"contact_lists": [
"9f3c2a1e-8b7d-4e2f-a1c6-2d4f5e6a7b8c"
]
}'
Custom Fields
Set custom field values by including the custom_fields array. Each field_key
must reference a custom field that already exists in your account; unknown keys
are rejected with 422.
| Field | Type | Required | Description |
|---|---|---|---|
field_key | string | Yes | The unique key of the custom field. |
value | mixed | Yes (key must be present) | The value to set (type depends on the field type). On the update branch, send null or "" to delete the value; omit a field to leave it unchanged. |
The accepted value for each field type — including the supported date and
datetime formats — is the same as for Create Contact.
curl -X POST "https://email.easy.tools/api/v1/contacts/upsert" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"match_on": "email",
"email": "john@example.com",
"custom_fields": [
{"field_key": "company", "value": "Acme Inc"},
{"field_key": "plan", "value": "enterprise"},
{"field_key": "old_field", "value": null}
]
}'
Response
New Contact (201 Created)
{
"data": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe",
"phone": null,
"external_id": "customer_123",
"language": null,
"country_code": "US",
"lists": [],
"custom_fields": [],
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
}
Existing Contact Updated (200 OK)
{
"data": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe",
"phone": "+48123456789",
"external_id": "customer_123",
"language": "pl",
"country_code": "US",
"lists": [
{
"uuid": "9f3c2a1e-8b7d-4e2f-a1c6-2d4f5e6a7b8c",
"name": "Newsletter"
}
],
"custom_fields": [
{
"field_key": "company",
"value": "Acme Inc"
}
],
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-16T14:00:00Z"
}
}
Response Fields
The response is the full contact representation, identical to Get Contact and Create Contact.
| Field | Type | Description |
|---|---|---|
uuid | string | Unique identifier for the contact |
email | string | Contact's email address |
first_name | string|null | Contact's first name |
last_name | string|null | Contact's last name |
phone | string|null | Contact's phone number |
external_id | string|null | External identifier for integration purposes |
language | string|null | ISO 639-1 language code |
country_code | string|null | ISO 3166-1 alpha-2 country code |
lists | array | Lists the contact is subscribed to. Each entry has uuid and name. |
custom_fields | array | Custom field values set on the contact. Each entry has field_key and value. |
created_at | string | ISO 8601 timestamp (UTC) |
updated_at | string | ISO 8601 timestamp (UTC) |
Error Responses
Not Found Error (404) — Contact Lists
Returned when one or more UUIDs in contact_lists do not exist for your account.
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Contact lists not found: 9f3c2a1e-8b7d-4e2f-a1c6-2d4f5e6a7b8c"
}
}
Not Found Error (404) — Timezone
Returned when timezone_uuid does not match a timezone from List Timezones. The contact is neither created nor updated in this case.
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Timezone not found"
}
}
Duplicate Contact Error (409 Conflict)
Returned when a unique key in the body belongs to a different contact than the one you matched on:
match_onisexternal_id, no contact has thatexternal_id, but theemailin the body already belongs to another contact.match_onisemail, a contact is matched, but theexternal_idin the body is already used by another contact.
{
"error": {
"code": "DUPLICATE_RESOURCE",
"message": "Contact with email 'john@example.com' already exists"
}
}
Validation Error (422 Unprocessable Entity)
Returned when match_on is missing or not one of external_id / email, when
external_id is missing while match_on is external_id, or when another field
fails validation.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The given data was invalid",
"details": {
"match_on": ["The selected match on is invalid."],
"external_id": ["The external id field is required when match on is external_id."]
}
}
}
Custom Field Validation Error (422 Unprocessable Entity)
Returned when a value in custom_fields cannot be applied — for example an
unknown field_key, a select value outside the field's options, a value of
the wrong type, or a text value over 255 characters. The specific reason is in
details.custom_fields.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Custom field validation failed",
"details": {
"custom_fields": ["Custom field 'plan' value 'invalid_option' is not a valid option. Allowed options: free, pro, enterprise"]
}
}
}