Skip to main content

Manage Contacts

Contacts represent the people your team communicates with across all messaging channels. This guide covers full CRUD operations, contact owners, tagging, blocking, bulk actions, import and export.

Required Scopes​

ScopePurpose
contacts:readList and view contacts
contacts:createCreate contacts and import CSV / Excel files
contacts:updateUpdate contact details, add tags, block / unblock, bulk actions
contacts:deleteDelete contacts
contacts:exportExport contacts to CSV / Excel
messages:read / messages:deleteTogether with contacts:read: list / delete a contact's files

Key Contact Fields​

FieldDescription
idUnique contact identifier
nameDisplay name
emailEmail address — reflects the contact's primary email contact method (see note below)
phonePhone number (E.164 format, e.g., +14155551234) — reflects the contact's primary phone contact method (see note below)
contact_methodsArray of contact methods (platform IDs like WhatsApp, Telegram, etc.)
custom_fieldsTyped custom field values, keyed by field key (see Custom Fields)
tagsArray of associated tags
owner_user_idID of the team member who owns the contact (null = no owner). See Contact Owner
ownerThe owner as {id, name, avatar_url, availability_state, absent_until}, null without an owner. availability_state is on_shift, off_hours, absent or inactive; absent_until is the UTC end of the owner's out-of-office period (null when not absent, or absent until switched off)
is_blockedtrue when the contact is blocked
blocked_inbound_count / last_blocked_inbound_atHow many inbound messages were dropped because the contact is blocked, and when the last one arrived
phone and email are derived from contact methods

contact_methods is the source of truth for how to reach a contact. The top-level phone and email fields are a convenience view: each is derived from the contact's primary phone / email contact method, so they always match the corresponding entry in contact_methods. A contact can carry several phone or email methods; the primary one is what surfaces in phone / email. If a contact has no phone (or no email) method at all, that field is null. To read or change every address a contact holds — or to control which one is primary — work with contact_methods (see Contact Method Types) rather than the convenience fields.

Contact Method Types​

A contact is identified by one or more contact methods. Each method has a method_type, a value, and — for channel-scoped types — a channel_id.

method_typeValue formatchannel_idNotes
phoneE.164, e.g. +14155551234Not usedAlso used for SMS and RCS. Optional phone_type: mobile, fixed_line, fixed_line_or_mobile, voip or unknown (other values return 422); inferred from the number when omitted
emailEmail addressNot usedCarries its own deliverability status
whatsapp_idWhatsApp ID without a leading +, e.g. 34612345678; alphanumeric IDs are also acceptedNot usedWorkspace-wide. Phone-style formatting (+, spaces, dashes, parentheses, periods) is stripped
whatsapp_bsuidWhatsApp Business-scoped user ID, e.g. US.13491208655302741918RequiredStored verbatim, periods included. See Migrating to BSUIDs
telegram_idTelegram user IDNot usedWorkspace-wide
messenger_idFacebook page-scoped ID (PSID)RequiredOnly valid for the Page that issued it
instagram_idInstagram-scoped ID (IGSID)RequiredOnly valid for the account that issued it
linkedin, homepage, facebookURL (https:// is prepended when omitted)Not usedSocial profile, not addressable
instagram_handleHandle without @Not usedSocial profile, not addressable
social_otherFree-formNot useddisplay_name is required (e.g. "TikTok")
Channel-scoped methods

whatsapp_bsuid, messenger_id, and instagram_id are channel-scoped: the identifier only means something to the channel (or, for WhatsApp, the WhatsApp Business portfolio) that issued it. POST /contacts/{id}/methods returns 400 if channel_id is missing for these types.

One WhatsApp identifier per contact is not a safe assumption

A contact can hold a whatsapp_id and a whatsapp_bsuid, or a whatsapp_bsuid and no phone number at all. Contacts who adopted a WhatsApp username have no phone or whatsapp_id method, because WhatsApp never discloses their number. Integrations that key on a single WhatsApp identifier, or that treat a missing phone number as bad data, will break on those contacts — see Migrating to BSUIDs.

Posting a Business-scoped user ID into the whatsapp_id slot returns 422: that slot normalizes phone numbers and would strip the periods, producing a value WhatsApp rejects at send time.

Verified Identifiers​

Each contact method carries a verification state. A verified identifier is confirmed to belong to the contact; AI agents rely only on verified identifiers when it matters who the customer is.

Response fieldTypeDescription
verifiedbooleantrue when the identifier is confirmed
verified_atdatetime | nullWhen it was verified
verified_sourcestring | nullchannel_native, inbound, manual, import, api, token or backfill

How identifiers become verified:

  • Platform IDs (telegram_id, messenger_id, instagram_id, whatsapp_bsuid) are verified when created (channel_native).
  • Phone and WhatsApp numbers are verified when the contact messages you from them (inbound). Inbound email never verifies an address, because sender addresses can be spoofed, and details typed into the live chat widget are never verified automatically.
  • Your integration can send "verified": true on a contact_methods item in POST /contacts, on POST /contacts/{id}/methods or on PUT /contacts/{id}/methods/{method_id} (api). Sending it on an existing unverified method upgrades it; omitting it never removes verification. On PUT, "verified": false removes it, and changing the value of any method (platform IDs included) resets it to unverified unless "verified": true is sent in the same request.
  • CSV import with identifiers_verified=true verifies imported phone, email and WhatsApp numbers (import).
  • Contact tokens verify the methods you send with "verified": true (token).

A method someone on your team marked as unverified stays unverified. New inbound messages from it do not verify it again; only an explicit verify (manual, API, import or contact token) does.

For how verified identifiers decide what your AI agents may share with a customer, see Customer context.

Mark or unmark a method​

PATCH /api/v1/contacts/{contact_id}/methods/{method_id}/verification
curl -X PATCH "https://api.sendseven.com/api/v1/contacts/{contact_id}/methods/{method_id}/verification" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{"verified": true}'

Requires contacts:update. Returns the updated contact method with verified, verified_at and verified_source (manual). Every change is recorded in the activity log. OAuth apps and embedded views receive 403 with code: verification_not_allowed. An unknown contact or method returns 404.

Only verify what you confirmed

Do not send verified: true for identifiers a user simply typed in. Mark them verified only after a login, a double opt-in or a similar check.

List Contacts​

GET /api/v1/contacts

Query Parameters​

ParameterTypeDefaultDescription
searchstringSearch by name, phone, email or contact / method ID
tag_idstringFilter by tag ID. Repeatable (?tag_id=a&tag_id=b) to match contacts with any of the tags
list_idstringFilter by list membership. 422 when the list is not in your workspace
list_subscription_statusstringsubscribedWith list_id: subscribed, pending, unsubscribed, bounced, complained, any (any status) or not_subscribed (contacts not on the list). For a dynamic list, subscribed / any return the contacts matching its conditions and not_subscribed the others
list_subscription_channelstringWith list_id: only subscriptions on this channel (email, sms, whatsapp, telegram, messenger, instagram, browser_push)
custom_field_idstringCustom field filter. Alone: contacts with any value in that field
custom_field_valuestringWith custom_field_id: the value to compare. Text fields match as "contains"; select fields need an exact option (422 otherwise)
custom_field_opstringeq, contains, not_contains, gt, lt, gte, lte or between. gt…between only on number fields, contains and not_contains only on text fields (422 otherwise). Number comparisons are numeric. not_contains needs a non-empty custom_field_value (422 otherwise) and also returns contacts that have no value for the field
custom_field_value_tostringUpper bound for between
is_blockedbooleantrue: only blocked contacts, false: only unblocked. Omit for both
include_live_chatbooleanfalseInclude anonymous Live Chat visitors (contacts that only have a live chat identifier)
includestringComma-separated extras: subscriptions (active list subscriptions: list_id, list_name, channel_type, status of subscribed/pending), conversation_stats (conversation_count, last_conversation_at). Unknown values return 422; omitted extras are null
owner_user_idstringContact owner filter: a team member's user ID, me (contacts you own, needs a user token) or none (contacts without an owner). Combinable with every other filter
sort_bystringcreated_atcreated_at, name, email or custom_field (needs custom_field_id)
sort_dirstringdescasc or desc
pageinteger1Page number
page_sizeinteger20Items per page (max 250)

All filters combine with AND.

curl​

curl -X GET "https://api.sendseven.com/api/v1/contacts?search=garcia&page=1&page_size=20" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json"

Response​

{
"items": [
{
"id": "contact_d4e5f6a7",
"name": "Maria Garcia",
"email": "[email protected]",
"phone": "+34612345678",
"contact_methods": [
{"id": "cm_wa_001", "method_type": "whatsapp_id", "value": "34612345678", "channel_id": null},
{"id": "cm_wab_001", "method_type": "whatsapp_bsuid", "value": "ES.13491208655302741918", "channel_id": "ch_123"}
],
"custom_fields": {
"company": "Acme Corp",
"role": "Product Manager"
},
"tags": [
{"id": "tag_vip", "name": "VIP", "color": "#EF4444"}
],
"created_at": "2026-01-10T08:00:00Z",
"updated_at": "2026-02-05T11:30:00Z"
}
],
"pagination": {
"page": 1,
"page_size": 20,
"total": 2,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
}

Create a Contact​

At least one identifier (email, phone, or platform ID) is required.

POST /api/v1/contacts

Request Body​

FieldTypeRequiredDescription
namestringNoDisplay name
emailstringNoEmail address
phonestringNoPhone number in E.164 format
contact_methodsarrayNoArray of contact methods (e.g., [{"method_type": "whatsapp_id", "value": "1234567890"}]). Channel-scoped types (whatsapp_bsuid, messenger_id, instagram_id) must include a channel_id. See Contact Method Types
custom_fieldsobjectNoTyped custom field values keyed by field definition id or field key. Fields must already be defined; values are validated per field type (422 on any invalid entry, nothing is written). See Custom Fields
owner_user_idstringNoContact owner: user ID of an active team member
tag_idsstring[]NoContact tags to attach (max 20, duplicates removed). Needs contacts:update in addition to contacts:create. See Tags on create

curl​

curl -X POST "https://api.sendseven.com/api/v1/contacts" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Sophie Martin",
"email": "[email protected]",
"phone": "+33612345678",
"contact_methods": [
{"method_type": "telegram_id", "value": "123456789"},
{"method_type": "whatsapp_id", "value": "33612345678"}
],
"custom_fields": {
"company": "TechStart SAS",
"lead_source": "Website form"
}
}'

Python​

import requests

BASE_URL = "https://api.sendseven.com/api/v1"
HEADERS = {
"Authorization": "Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00",
"Content-Type": "application/json",
}

# Create a contact
new_contact = requests.post(
f"{BASE_URL}/contacts",
headers=HEADERS,
json={
"name": "Alex Johnson",
"email": "[email protected]",
"phone": "+12025551234",
"custom_fields": {"lead_source": "API", "plan": "enterprise"}, # fields must be defined first
},
)
contact = new_contact.json()
print(f"Created: {contact['id']}")

# Search contacts
results = requests.get(
f"{BASE_URL}/contacts",
headers=HEADERS,
params={"search": "Johnson", "page_size": 10},
)
for c in results.json()["items"]:
print(f" {c['name']} - {c['email']}")

JavaScript​

const BASE_URL = "https://api.sendseven.com/api/v1";
const HEADERS = {
"Authorization": "Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00",
"Content-Type": "application/json",
};

// Create a contact
const response = await fetch(`${BASE_URL}/contacts`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
name: "Alex Johnson",
email: "[email protected]",
phone: "+12025551234",
}),
});
const contact = await response.json();

// Update the contact
await fetch(`${BASE_URL}/contacts/${contact.id}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify({
custom_fields: { company: "New Corp", role: "CTO" }, // merged; null clears a field
}),
});

Response (201 Created)​

{
"id": "contact_f6a7b8c9",
"name": "Sophie Martin",
"email": "[email protected]",
"phone": "+33612345678",
"contact_methods": [
{"id": "cm_tg_001", "method_type": "telegram_id", "value": "123456789"},
{"id": "cm_wa_002", "method_type": "whatsapp_id", "value": "33612345678"}
],
"custom_fields": {
"company": {
"field_definition_id": "cf-1a2b3c4d",
"name": "Company",
"field_type": "text",
"value": "TechStart SAS"
},
"lead_source": {
"field_definition_id": "cf-5e6f7a8b",
"name": "Lead Source",
"field_type": "select",
"value": "Website form"
}
},
"tags": [],
"created_at": "2026-02-10T16:00:00Z",
"updated_at": "2026-02-10T16:00:00Z"
}

Tags on create​

Send tag_ids to tag the new contact in the same call instead of one POST /contacts/{contact_id}/tags/{tag_id} per tag:

curl -X POST "https://api.sendseven.com/api/v1/contacts" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Sophie Martin",
"phone": "+33612345678",
"custom_fields": {"location": "Augsburg"},
"tag_ids": ["tag_vip", "tag_newsletter"]
}'
  • Scope: tagging needs contacts:update, the same scope as the single-tag endpoint. A request that sends tag_ids without it is refused with 403, "code": "insufficient_scope" and "missing_scopes": ["contacts:update"]; nothing is created. Requests without tag_ids only need contacts:create, as before.
  • Validation: every ID must be a tag of your workspace. Otherwise the request fails with 422, "code": "unknown_tag" and the unknown IDs in tag_ids; nothing is written.
  • Events: each tag fires contact.tag_added (webhook and flow trigger), exactly like the single-tag endpoint. contact.created is sent first, so a flow started by contact.created does not see the tags yet; use the tag trigger for tag-based automation.
  • Response: the tags array of the 201 response contains the attached tags.
  • auto_merge: when the identifiers match an existing contact, the tags are added to that contact only if you may edit it (same rule as custom_fields); otherwise they are skipped and the 201 still returns the matched contact.

Custom fields marked as required are enforced in the SendSeven app only. The API accepts a contact without them, so integrations and automatically created contacts keep working.

Create a contact with no phone number (WhatsApp Business-scoped user ID)​

A WhatsApp Business-scoped user ID is a valid identifier on its own, so a contact can be created with no phone, email, or whatsapp_id. The channel_id is required and must be the WhatsApp channel the ID was issued for.

curl -X POST "https://api.sendseven.com/api/v1/contacts" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Alex Rivera",
"contact_methods": [
{
"method_type": "whatsapp_bsuid",
"value": "US.13491208655302741918",
"channel_id": "ch_123",
"display_name": "@alexrivera"
}
]
}'

The display_name is where a public WhatsApp username lives when one is known. It is a label only — you cannot send to a username, only to the Business-scoped user ID.

Get a Contact​

GET /api/v1/contacts/{contact_id}
curl -X GET "https://api.sendseven.com/api/v1/contacts/contact_d4e5f6a7" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json"

Update a Contact​

All fields are optional. Only included fields are updated. custom_fields is merged: only the fields you send are changed, and null clears a field. Invalid custom field values return 422 and nothing is updated. PATCH /api/v1/contacts/{contact_id} behaves the same way.

languages also sets the preferred language. The first entry of languages is always the contact's default language and is mirrored into language, the language bots and flows reply in; reorder the list to change the default. It counts as set by a teammate, so automatic language detection does not overwrite it (only the contact explicitly asking an AI agent for another language does). Inbox language rules and flow language conditions match any code in languages as well as the preferred language. Sending languages: null (or "") also clears a preferred language that was set this way; a detected language stays.

Without contacts:read the response only echoes the contact's id, tenant_id and created_at plus the fields you sent (see Responses Without the contacts:read Scope).

PUT /api/v1/contacts/{contact_id}
curl -X PUT "https://api.sendseven.com/api/v1/contacts/contact_d4e5f6a7" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Maria Garcia-Lopez",
"custom_fields": {
"company": "Acme Corporation",
"role": "VP of Product"
}
}'

Contact Owner​

A contact can have one owner: the team member responsible for the customer. New conversations of an owned contact are routed to the owner (with the workspace's routing rules and out-of-office substitutes applied).

  • Read: every contact carries owner_user_id and owner.

  • Filter: GET /contacts?owner_user_id=<user_id>|me|none. POST /contacts/export accepts the same filter.

  • Set or clear: send owner_user_id on POST /contacts, PUT or PATCH /contacts/{contact_id}; null removes the owner. The owner must be an active member of the workspace, otherwise the request fails with 422 and nothing is written:

    {"detail": {"code": "invalid_contact_owner", "message": "..."}}

    Setting an owner also hands the contact's open, unassigned conversations to the owner (their substitute while out of office).

  • Create with auto-merge: when POST /contacts merges into an existing contact (see Duplicates & Merging), the existing contact only takes the owner if it has none yet and your token also holds contacts:update. An existing owner is never replaced by a create.

  • Webhook: contact.updated carries owner_user_id inside data.contact and is sent whenever the owner changes.

curl -X PATCH "https://api.sendseven.com/api/v1/contacts/contact_d4e5f6a7" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{"owner_user_id": "usr_7f3a9c"}'

# Everything Anna owns
curl "https://api.sendseven.com/api/v1/contacts?owner_user_id=usr_7f3a9c" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"

Find user IDs with GET /api/v1/users (scope team:read). Owners can also be set in bulk (the set_owner bulk action, which can be undone) and by import (column owner_email, email or user ID). To see whether an owner is currently reachable, read owner.availability_state; the Availability and Out of Office endpoints let a member (or an admin for a colleague) change it.

Delete a Contact​

Permanently deletes a contact and all of their conversations, including every message and attachment in them. This cannot be undone. Requires the contacts:delete scope.

To delete just one conversation and keep the contact, use Delete a Conversation.

Every contact delete (single, bulk, API or integration) is logged with who deleted it and when. The log keeps only the contact's ID, never the contact's name, phone number, email or messages. The contact's history, including its deleted-conversation entries, is removed with the contact.

DELETE /api/v1/contacts/{contact_id}
curl -X DELETE "https://api.sendseven.com/api/v1/contacts/contact_e5f6a7b8" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
{
"success": true,
"message": "Contact deleted successfully",
"details": { }
}

details contains counts of the removed records and may change over time; do not rely on its keys.

Tags on a Contact​

Add or remove one tag at a time (scope contacts:update). Both calls are idempotent.

# Add
curl -X POST "https://api.sendseven.com/api/v1/contacts/contact_d4e5f6a7/tags/tag_vip" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"

# Remove
curl -X DELETE "https://api.sendseven.com/api/v1/contacts/contact_d4e5f6a7/tags/tag_vip" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
{"success": true, "message": "Tag added to contact"}

To tag many contacts at once, use the add_tags / remove_tags bulk actions. See Tags & Lists for creating tags.

Contact Files​

List every file exchanged with a contact, across all channels and conversations, newest first. Scopes: contacts:read and messages:read.

GET /api/v1/contacts/{contact_id}/attachments?page_size=30&cursor=<next_cursor>
{
"items": [
{
"attachment_id": "8d1c...",
"filename": "receipt.jpg",
"content_type": "image/jpeg",
"category": "image",
"file_size": 184233,
"url": "https://storage.googleapis.com/...signed...",
"thumbnail_url": null,
"direction": "inbound",
"created_at": "2026-11-06T09:12:44Z",
"conversation_id": "c0f3...",
"message_id": "5b2e...",
"source": "whatsapp_flow",
"deletable": true
}
],
"next_cursor": "eyJ...",
"has_more": true
}
  • source: message (a chat or media message, including template and interactive header media), email (an email file attachment) or whatsapp_flow (a photo or document the contact uploaded in a WhatsApp Flow). An email or flow reply with several files returns one item per file, all on the same page.
  • deletable: whether the file can be deleted with the endpoint below. It is false for reusable library files: template or interactive header media, files saved in the Media library, and files reused by other messages, campaigns or templates.
  • url and thumbnail_url are signed links that expire after about 60 minutes. Fetch the page again for fresh links.
  • Pagination is keyed on messages: page_size bounds the messages per page, so items can be longer than page_size.

Delete a contact file​

DELETE /api/v1/contacts/{contact_id}/attachments/{attachment_id}

Scopes: contacts:read and messages:delete. Permanently deletes the stored file. The message it came with is kept.

{"success": true, "id": "8d1c..."}
StatusMeaning
200Deleted
404The file is not one of this contact's files as GET /contacts/{contact_id}/attachments lists them for you (wrong contact, another workspace, a conversation you cannot open, or already deleted)
409{"detail": {"code": "attachment_is_library_asset"}}: the item has deletable: false. Manage it where it is reused (for example the Media library)

After a delete:

  • A message whose file was deleted returns attachment_deleted: true and an empty attachments list on GET /api/v1/messages and GET /api/v1/messages/{id}. Show a "file deleted" placeholder.
  • For a WhatsApp Flow file, the entry in meta.flow_response.media keeps media_id, file_name and mime_type but changes to "status": "deleted" and "attachment_id": null. response_data is unchanged.
  • For an email file, the email thread no longer lists the file.

Block a Contact​

Blocking stops a contact from reaching you and stops you from writing to them. Scope contacts:update.

curl -X POST "https://api.sendseven.com/api/v1/contacts/contact_d4e5f6a7/block" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
{"success": true, "contact_id": "contact_d4e5f6a7", "is_blocked": true}

POST /contacts/{contact_id}/unblock reverses it and returns "is_blocked": false. Both calls are idempotent and send a contact.updated webhook when the state changes.

  • Inbound: messages from any of the contact's phone numbers, email addresses or channel IDs are dropped. They create no conversation and no notification; they are only counted in blocked_inbound_count and last_blocked_inbound_at.
  • Survives deletion: verified identifiers and channel IDs (WhatsApp, Telegram, Messenger, Instagram and so on) stay blocked even if the contact is deleted and created again. Unverified, manually entered identifiers are blocked only while the contact exists and is blocked.
  • Unblock clears every identifier block of the contact, including its addresses on the email sender block list.
  • Outbound: every send to a blocked contact (messages, rich messages, live chat, private replies to comments, WhatsApp Flows, calls) fails with 409:
{
"detail": {
"error": "CONTACT_BLOCKED",
"message": "This contact is blocked. Unblock the contact to send messages.",
"contact_id": "contact_d4e5f6a7"
}
}

contact_id is null when you addressed the recipient by a raw phone number, email or conversation instead of a contact ID.

List blocked contacts with GET /contacts?is_blocked=true. Members restricted to certain custom inboxes can only block contacts they can reach (others return 404).

Bulk Actions​

Change many contacts in one job: set fields, tags, list membership, block state or owner. All endpoints need contacts:update. A bulk action always runs in two steps, so a filter that suddenly matches far more contacts than expected never runs unnoticed:

  1. Preview returns how many contacts would change, a small sample, and a confirmation_token (valid about 10 minutes).
  2. Execute with the identical body plus the token starts the job.
curl -X POST "https://api.sendseven.com/api/v1/contacts/bulk-actions/preview" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"selection": {"mode": "filter", "filter": {"list_id": "list_newsletter", "owner_user_id": "none"}},
"actions": [
{"type": "set_owner", "owner_user_id": "usr_7f3a9c"},
{"type": "add_tags", "tags": ["tag_vip"]}
]
}'
{
"affected_count": 412,
"sample": [{"id": "contact_d4e5f6a7", "display_name": "Maria Garcia"}],
"confirmation_token": "eyJ...",
"expires_in_seconds": 600
}
curl -X POST "https://api.sendseven.com/api/v1/contacts/bulk-actions/execute" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"selection": {"mode": "filter", "filter": {"list_id": "list_newsletter", "owner_user_id": "none"}},
"actions": [
{"type": "set_owner", "owner_user_id": "usr_7f3a9c"},
{"type": "add_tags", "tags": ["tag_vip"]}
],
"confirmation_token": "eyJ..."
}'

Execute returns 202 with {"bulk_action_id": "...", "status": "pending", "poll_url": "..."}.

Selection

modeBody
ids{"mode": "ids", "ids": [...]}, up to 10,000 contact IDs
filter{"mode": "filter", "filter": {...}} with the list filters search, include_live_chat, custom_field_id / custom_field_value / custom_field_value_to / custom_field_op, list_id, list_subscription_status, list_subscription_channel, is_blocked, owner_user_id. An empty filter selects every contact. Needs contacts:read as well

Actions (1 to 20 per job, applied in order)

typeFields
set_fieldfield (name, display_name, first_name, last_name, language, languages, birthday), value. languages takes comma-separated ISO 639-1 codes (as on PATCH /contacts/{id}) and also sets language to the first code; clearing languages clears language only if it was set by a person. A language set this way, or directly, is never overwritten by automatic language detection
set_custom_fieldkey, value
add_tags / remove_tagstags: tag IDs
add_to_listlist_id, channel_type (email default, sms, whatsapp). Contacts that already have a subscription row on that list, in any status, are skipped
set_blockedblocked: true / false (same effect as blocking)
set_ownerowner_user_id (or null to clear), propagate (default true: also hand open, unassigned conversations to the new owner)

Errors (in detail.code)

StatuscodeMeaning
400TOKEN_INVALID / TOKEN_EXPIREDRun the preview again
403FILTER_REQUIRES_CONTACTS_READA filter selection needs contacts:read
409PAYLOAD_MISMATCHThe execute body differs from the previewed one
409AFFECTED_COUNT_DRIFTThe selection changed since the preview (any difference below 100 contacts, more than 2% above). Preview again

Status and undo

  • GET /contacts/bulk-actions/{bulk_action_id}: status (pending, processing, completed, failed), affected_count (contacts actually changed), selection, actions, error, created_at, started_at, completed_at.
  • GET /contacts/bulk-actions: your workspace's jobs, paginated.
  • POST /contacts/bulk-actions/{bulk_action_id}/undo: restores the previous owners of a completed job that contained only set_owner. Returns {"success": true, "bulk_action_id": "...", "restored": 398, "skipped": 14}; other jobs return 422 (not_undoable). Conversations already handed to the new owner stay assigned.

Webhooks: every changed contact sends its normal events (contact.updated with the changed fields, contact.tag_added, contact.tag_removed, contact.subscribed) with data.origin set to "bulk", so your integration can tell them apart from single edits. They are sent in batches while the job runs.

Import Contacts​

Import contacts from a CSV or Excel file. The import runs as a background job: upload the file, then poll the job until it finishes. Scope contacts:create.

POST /api/v1/email-contacts/import
curl -X POST "https://api.sendseven.com/api/v1/email-contacts/import?list_id=list_newsletter" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-F "[email protected]" \
-F "duplicate_mode=auto_merge" \
-F 'apply_tag_ids=["tag_imported"]'
{
"job_id": "imp_3f9a2c",
"status": "queued",
"message": "Contact import started. Poll the status endpoint for progress.",
"poll_url": "/api/v1/email-contacts/import/imp_3f9a2c/status"
}

The file is sent as multipart field file (.csv or .xlsx, up to 10 MB; larger files return 413, other types 400). The path says email-contacts for historical reasons; the import handles every identifier, not only email.

Query parameters

ParameterDescription
list_id / list_idsAdd the imported contacts to one list, or several (comma-separated)
create_subscriptionCreate list subscriptions for the imported contacts
optin_confirmedConfirms you hold consent for the subscriptions you create
identifiers_verifiedtrue marks the imported phone, email and whatsapp_id values as verified. Use it only for identifiers you confirmed yourself. Not available to OAuth apps or embedded logins (403, verification_not_allowed). Also accepted as a form field

Form fields (all optional)

FieldDescription
column_mappingJSON object mapping contact fields to your column headers, for example {"email": "E-Mail", "name": "Full Name", "phone": "Telefon"}. Without it, standard headers (the export columns) are recognised
duplicate_modeauto_merge, allow_duplicates, dont_allow_duplicates or update_existing. Defaults to the workspace setting (see Duplicates & Merging)
update_field_modeWith duplicate_mode=update_existing: overwrite (default) or fill_empty (only fill fields that are empty)
match_priorityWhich identifier finds an existing contact first: email (default), phone or whatsapp_id
subscriptionsJSON array [{"channel_type": "email" | "sms" | "whatsapp", "list_id": "..."}]. Needs optin_confirmed=true (else 400)
apply_tag_idsJSON array of tag IDs added to every contact the import creates or updates
apply_custom_fieldJSON object {"field_id": "...", "value": ...} set on every contact the import creates or updates
delimiter / quotecharCSV delimiter (, ; tab |) and quote character
default_phone_regionCountry used for phone numbers without a + prefix, for example DE

Columns. A row needs at least one of contact_id, email, phone, whatsapp_id, display_name, first_name or last_name. Further recognised columns: languages, birthday, tags, social profiles, custom.<key> for custom fields, and the owner column (owner_email; also owner, owner_user_id, owner_id, contact_owner, contact_owner_email). The owner is a team member's email (case-insensitive) or user ID; an unknown owner adds an owner_not_found row warning and imports the row without an owner. Blank cells never clear existing values. A file produced by export imports again without any mapping.

first_name,last_name,email,phone,owner_email,custom.company
Sophie,Martin,[email protected],+33612345678,[email protected],TechStart SAS
James,Wilson,[email protected],+447911123456,,UK Solutions Ltd
WhatsApp Business-scoped user IDs cannot be imported

There is no whatsapp_bsuid import column. Those IDs are channel-scoped and issued by WhatsApp, so they are only created by inbound messages or by POST /contacts/{id}/methods with an explicit channel_id. A contact export does include a whatsapp_bsuid column for reference; re-importing that column has no effect.

Import status​

curl "https://api.sendseven.com/api/v1/email-contacts/import/imp_3f9a2c/status" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
{
"job_id": "imp_3f9a2c",
"status": "completed",
"progress_percent": 100,
"total_items": 2,
"processed_items": 2,
"failed_items": 0,
"summary": {
"total": 2,
"imported": 1,
"updated_count": 0,
"merged_count": 1,
"duplicates": 0,
"invalid_rows": 0,
"tags_applied": 2,
"row_errors": [],
"row_warnings": []
}
}

status is queued, processing, completed, failed or cancelled. summary appears once the job is completed or cancelled and lists what happened per row (row_errors, row_warnings, capped at 2,000 entries) plus totals such as imported, merged_count, updated_count, duplicate_blocked_count, subscriptions_created, tags_applied and custom_field_values_written. A job that makes no progress for 15 minutes is marked failed (orphaned: true).

The status endpoint accepts contacts:read or contacts:create. Without contacts:read, IDs of existing contacts and list names are left out of the summary.

EndpointPurpose
GET /email-contacts/import/jobs?status=active|allYour workspace's import jobs, paginated (page_size up to 100)
POST /email-contacts/import/{job_id}/cancelStop a running import after the current batch (202; 409 when it already finished). Rows already imported stay; the job ends as cancelled with a partial summary
GET /email-contacts/import/template?format=csv|xlsxA template file with the standard columns, plus one custom.<key> column per active custom field when your API key also has custom_fields:read

Export Contacts​

Export contacts to CSV or Excel as a background job. Scope contacts:export. Every export request appears in the workspace's API audit log (GET /audit-log), which keeps entries for 14 days.

curl -X POST "https://api.sendseven.com/api/v1/contacts/export?format=xlsx&owner_user_id=usr_7f3a9c&is_blocked=false" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
{
"job_id": "exp_81bd0e",
"status": "queued",
"message": "Contact export started. Poll the status endpoint for progress.",
"poll_url": "/api/v1/contacts/export/exp_81bd0e/status",
"download_url": "/api/v1/contacts/export/exp_81bd0e/download"
}

Query parameters: format (csv default, xlsx) plus the list filters search, include_live_chat, custom_field_id / custom_field_value / custom_field_value_to / custom_field_op, list_id, list_subscription_status, list_subscription_channel, is_blocked and owner_user_id. Without filters, every contact is exported.

  • GET /contacts/export/{job_id}/status returns status (queued, processing, completed, failed, cancelled), progress_percent, total_contacts, filename, columns, error_message, error_code and, once completed, download_url, expires_at and expired. When status is failed, error_code is export_failed and error_message is a fixed, generic text; otherwise error_code is null. Retry the export.
  • GET /contacts/export/{job_id}/download redirects (302) to the file. The link is valid for 60 minutes. Add ?redirect=false to get {"download_url": "...", "filename": "..."} instead. 409 while the export is still running, 404 for an unknown job.
  • Export files are deleted 7 days after the export finishes (expires_at). After that, the status shows expired: true without a download_url, and the download endpoint returns 410 Gone. Start a new export to get a fresh file.

Columns: contact_id, first_name, last_name, display_name, email, phone, whatsapp_id, whatsapp_bsuid, telegram_id, messenger_id, instagram_id, languages, birthday, tags, linkedin, instagram_handle, facebook, homepage, owner_email, then one custom.<key> column per custom field (multi-select values comma-separated).

Duplicates & Merging​

SendSeven detects duplicate contacts on every contact-creation path (REST, CSV import, live chat capture) using normalized phone, email, and platform identifiers. How a collision is handled depends on the duplicate mode in effect for that request.

A phone and a numeric whatsapp_id with the same digits are the same number: +4917012345678 as a phone matches a contact that only has the whatsapp_id 4917012345678, and the other way round. This applies to contact_methods in POST /contacts and to POST /contacts/{id}/methods. A method is never moved from one contact to another.

Operating Modes​

Three tenant-level modes control duplicate handling. The default for every account (existing and new) is auto_merge.

ModeBehavior
auto_mergeWhen a new contact has a phone, email, or platform ID that already exists in the tenant, the existing contact is updated instead of creating a duplicate. The oldest contact is kept as primary. Conflicting field values are recorded as a system note on the surviving contact.
allow_duplicatesA new contact is always created. The response includes a potential_duplicates array so the client can surface possible matches to the user.
dont_allow_duplicatesCreates that collide are rejected. The API returns 409 Conflict with the existing duplicates listed.

Change the tenant default in Settings → Contacts & Duplicates in the dashboard, or via the API:

curl -X PUT "https://api.sendseven.com/api/v1/tenants/{tenant_id}" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{ "duplicate_contact_mode": "allow_duplicates" }'

Per-Request Override​

Every contact-creation endpoint accepts an optional duplicate_mode field that overrides the tenant default for that single request. If omitted, the tenant default is used.

EndpointOverride FieldLocation
POST /contactsduplicate_modeJSON body
POST /contacts/{id}/methodsduplicate_modeJSON body
POST /email-contacts/importduplicate_modeMultipart form field

Allowed values: auto_merge, allow_duplicates, dont_allow_duplicates (the import also accepts update_existing).

An override can't loosen "Don't allow duplicates". When the workspace is set to dont_allow_duplicates, a request with duplicate_mode: "allow_duplicates" is handled as dont_allow_duplicates. This applies to every caller (dashboard users, API keys and OAuth apps):

EndpointWhat happens when the override is ignored
POST /contactsNo match: the contact is created as usual (201). Match: 409 with "override_ignored": true (see below).
POST /contacts/{id}/methodsA value another contact already has returns the 409 duplicate_contact_method conflict with "override_ignored": true.
POST /email-contacts/importRows that match an existing contact are blocked (counted in duplicate_blocked_count). The import status reports duplicate_mode: "dont_allow_duplicates" and duplicate_mode_override_ignored: true.

The other overrides (auto_merge, update_existing, dont_allow_duplicates) never create a second contact with the same details, so they work in every workspace. To allow duplicates, an admin changes the workspace setting.

Field-Merge Rules​

When auto_merge triggers (or a manual merge is performed), scalar fields are reconciled with these rules:

  • Non-empty wins. If only one contact has a value, that value is kept.
  • Older contact wins ties. If both contacts have the same field set to different non-empty values, the value from the contact with the earlier created_at is kept.
  • is_blocked and is_archived: any-true wins. This is sticky for safety -- a blocked contact cannot be silently unblocked through a merge.
  • All conversations, messages, notes, tags, custom fields, and contact methods from the merged-away contact are moved to the survivor. Tag and list memberships are de-duplicated.
  • A system note is appended to the survivor summarizing every conflict, e.g. "Merged from contact 42c1.... Conflicts: first_name='Max' kept, discarded ['Maxi'].".

Old UUID Resolution​

After a merge, the merged-away contact's UUID continues to resolve. GET /contacts/{old_id} returns:

  • HTTP 200 OK with the surviving contact's full body.
  • An additional merged_from object documenting the redirect.
  • A response header X-Merged-Into: {current_id} providing the new ID for clients that want to update their stored references.
curl -i -X GET "https://api.sendseven.com/api/v1/contacts/contact_old_b8c9d0e1" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
HTTP/1.1 200 OK
X-Merged-Into: contact_d4e5f6a7
Content-Type: application/json
{
"id": "contact_d4e5f6a7",
"name": "Maria Garcia",
"email": "[email protected]",
"phone": "+34612345678",
"merged_from": {
"old_id": "contact_old_b8c9d0e1",
"merged_at": "2026-04-12T10:23:00Z",
"reason": "auto"
},
"created_at": "2026-01-10T08:00:00Z",
"updated_at": "2026-04-12T10:23:00Z"
}

Inbound Channel Behavior​

When an inbound message arrives via WhatsApp, Telegram, or another channel for a contact that already has a matching phone or email but does not yet have the channel's specific platform ID (e.g. whatsapp_id), the new contact method is attached to the existing contact regardless of the duplicate mode.

This is not a duplicate -- it is a new method on an existing contact. Inbound message processing is therefore mode-agnostic.

WhatsApp Business-Scoped User IDs​

Inbound WhatsApp messages carry a Business-scoped user ID alongside the phone number, so an existing WhatsApp contact gains a whatsapp_bsuid method automatically the next time they write in. When WhatsApp discloses no phone number (the sender adopted a username), the whatsapp_bsuid method is created as the contact's primary method and no phone or whatsapp_id method is created at all.

If such a contact later shares their phone number, and that number already belongs to another contact record, the two are merged automatically -- the same person existed twice because the phone-number record predates the username. The whatsapp_bsuid record survives as the primary contact, the other record's ID keeps resolving via Old UUID Resolution, and field data is preserved by the usual field-merge rules. See Migrating to BSUIDs.

Example: Auto-Merge Create​

A second create with the same phone returns the existing contact instead of creating a new one.

curl -X POST "https://api.sendseven.com/api/v1/contacts" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Maxi Mustermann",
"phone": "+491701234567"
}'
{
"id": "contact_d4e5f6a7",
"name": "Max Mustermann",
"email": "[email protected]",
"phone": "+491701234567",
"contact_methods": [
{"id": "cm_ph_001", "method_type": "phone", "value": "+491701234567"}
],
"tags": [],
"created_at": "2026-01-10T08:00:00Z",
"updated_at": "2026-04-12T10:23:00Z"
}

A system note is added to the contact:

Merged from contact 42c1bbcb-7a09-4a1a-9b0f-3fdaae8c6e2b. Conflicts: name='Max Mustermann' kept, discarded ['Maxi Mustermann'].

Example: Allow Duplicates Create​

curl -X POST "https://api.sendseven.com/api/v1/contacts" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Maxi Mustermann",
"phone": "+491701234567",
"duplicate_mode": "allow_duplicates"
}'
{
"id": "contact_new_a9b8c7d6",
"name": "Maxi Mustermann",
"phone": "+491701234567",
"contact_methods": [
{"id": "cm_ph_044", "method_type": "phone", "value": "+491701234567"}
],
"potential_duplicates": [
{
"id": "contact_d4e5f6a7",
"name": "Max Mustermann",
"phone": "+491701234567",
"methods": [
{"method_type": "phone", "value": "+491701234567"}
]
}
],
"created_at": "2026-04-12T11:05:00Z",
"updated_at": "2026-04-12T11:05:00Z"
}

Example: Don't Allow Duplicates (409)​

curl -X POST "https://api.sendseven.com/api/v1/contacts" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Maxi Mustermann",
"phone": "+491701234567",
"duplicate_mode": "dont_allow_duplicates"
}'
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"detail": {
"detail": "duplicate_contact",
"duplicates": [
{
"id": "contact_d4e5f6a7",
"name": "Max Mustermann",
"phone": "+491701234567",
"email": null,
"methods": [
{"method_type": "phone", "value": "+491701234567", "channel_id": null, "is_primary": true}
]
}
]
}
}

To continue, use the existing contact (its id is in duplicates), or retry with duplicate_mode: "auto_merge" to update the existing contact instead. Retrying with "allow_duplicates" only creates a second contact when the workspace is not set to dont_allow_duplicates.

If the workspace is set to dont_allow_duplicates and the request sent duplicate_mode: "allow_duplicates", the same 409 also contains "override_ignored": true next to duplicates. The key is only present in that case. Retrying with allow_duplicates will not help; use the existing contact instead.

Who Sees Duplicate Details​

Other contacts named in a duplicate or conflict response are reduced to their ID unless your token also has contacts:read. See Responses Without the contacts:read Scope.

Manual Merge​

Merge one or more secondary contacts into a primary contact. Requires scope contacts:update.

POST /api/v1/contacts/{id}/merge
curl -X POST "https://api.sendseven.com/api/v1/contacts/contact_d4e5f6a7/merge" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"secondary_ids": ["contact_old_b8c9d0e1", "contact_old_e1f2a3b4"],
"primary_override": false
}'

By default the oldest contact in the set becomes the primary regardless of the path id. Set primary_override: true to force the path id ({id}) to be the primary.

{
"contact": {
"id": "contact_d4e5f6a7",
"name": "Maria Garcia",
"email": "[email protected]",
"phone": "+34612345678",
"contact_methods": [
{"id": "cm_wa_001", "method_type": "whatsapp_id", "value": "34612345678"},
{"id": "cm_tg_017", "method_type": "telegram_id", "value": "987654321"}
],
"tags": [
{"id": "tag_vip", "name": "VIP", "color": "#EF4444"}
],
"created_at": "2026-01-10T08:00:00Z",
"updated_at": "2026-04-12T10:23:00Z"
},
"merged_count": 2
}

List Duplicate Groups​

For bulk-merge UIs. Returns groups of contacts that share at least one of the requested signals. Requires scope contacts:read.

GET /api/v1/contacts/duplicates
ParameterTypeDefaultDescription
signalsstringphone,email,platform_idComma-separated list. Allowed: phone, email, platform_id, name. The name signal is exact match only and prone to false positives.
pageinteger1Page number
page_sizeinteger25Items per page (max 100)
curl -X GET "https://api.sendseven.com/api/v1/contacts/duplicates?signals=phone,email&page=1&page_size=25" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
{
"items": [
{
"signal": "phone",
"value": "+491701234567",
"contacts": [
{"id": "contact_d4e5f6a7", "name": "Max Mustermann", "created_at": "2026-01-10T08:00:00Z"},
{"id": "contact_new_a9b8c7d6", "name": "Maxi Mustermann", "created_at": "2026-04-12T11:05:00Z"}
]
}
],
"pagination": {
"page": 1,
"page_size": 25,
"total": 1,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
}

Responses Without the contacts:read Scope​

A token that can create or update contacts but lacks contacts:read (for example a write-only API key or OAuth app) never gets stored contact data back. Responses that would show an existing contact, or another contact, contain only IDs plus the values you sent yourself, echoed exactly as you sent them. Every other key is left out (not null, not a default such as "is_blocked": false). This applies to API keys, OAuth apps and dashboard users alike. If the scope can't be checked, the data is left out.

If you need the stored state after a write, also grant contacts:read and read the contact with GET /contacts/{id}.

Writes to a contact​

ResponseWithout contacts:read
PUT / PATCH /contacts/{id}id, tenant_id, created_at plus the fields you sent (name, first_name, last_name, phone, email, avatar_url, languages, birthday). A field you set to null comes back as null. custom_fields is not echoed.
POST /contacts 201 after an auto_merge matchThe matched contact's id, tenant_id and created_at, plus the fields you sent (name, first_name, last_name, phone, email, languages, birthday).
POST /contacts/{id}/merge{"contact": {"id", "tenant_id", "created_at"}, "merged_count": n}. The id is the surviving contact.
POST /contacts/{id}/methods 201id, contact_id plus the fields you sent (method_type, value, channel_id, display_name, phone_type, is_primary, verified).
POST /contacts/{id}/methods 201 with allow_duplicates (method already on another contact)id, contact_id, method_type, value and channel_id of that existing method.
PUT /contacts/{id}/methods/{method_id}id, contact_id plus the fields you sent.
PATCH /contacts/{id}/methods/{method_id}/verificationid, contact_id, verified.
POST /contacts/{id}/subscriptions 201id, tenant_id, contact_id, list_id, channel_type, status, opt_in_method plus the optional fields you sent.
PATCH /subscriptions/{id}id, tenant_id, contact_id, list_id, channel_type, status, opt_in_method plus the fields you sent.
DELETE /contacts/{id}/subscriptions/{subscription_id}{"success": true, "message": "...", "subscription": null}
PATCH /email-contacts/{id}id, tenant_id, contact_id plus the fields you sent.
POST /contacts/bulk-actions/previewsample is an empty list. affected_count and the confirmation token are returned as usual. A filter using search or any custom_field_* key is rejected with 403 FILTER_REQUIRES_CONTACTS_READ (on execute too); ids selections and filters on list_id, list_subscription_status, list_subscription_channel, is_blocked and include_live_chat still work.

A PATCH /contacts/{id} with {"first_name": "John"} without contacts:read:

{
"id": "contact_d4e5f6a7",
"tenant_id": "tenant_123",
"created_at": "2026-01-15T10:30:00Z",
"first_name": "John"
}

An auto_merge match on POST /contacts (you sent name and phone) looks the same: the id is the existing contact your data was merged into, and the other values are what you sent, not necessarily what is stored.

Duplicates and conflicts​

ResponseWithout contacts:read
POST /contacts 409 duplicates[][{"id": "contact_d4e5f6a7"}]
POST /contacts 201 potential_duplicates[][{"id": "contact_d4e5f6a7"}]
POST /contacts/{id}/methods 409other_contact is {"id": "contact_d4e5f6a7"} and duplicates is [{"id": "contact_d4e5f6a7"}]. The method_type, value and channel_id you sent are still returned.
Import status and cancelRow issues leave out which existing contact matched, and list names are left out.
Contact merge suggestions (list, confirm, dismiss)For API keys, OAuth apps and embedded views, source_contact and target_contact are {"id": "..."} only (no name or avatar). Signed-in users always see name and avatar. matched_value (what the visitor typed) is always returned.

The duplicate 409 without contacts:read:

{
"detail": {
"detail": "duplicate_contact",
"duplicates": [{"id": "contact_d4e5f6a7"}]
}
}

Error Responses​

Most errors return detail as a message string. The cases your code may want to branch on return an object; its code key is shown in the table (detail, error or code). See Error Handling for all response shapes.

StatusdetailDescription
401"Invalid or expired API token"Token is invalid, expired or revoked, or its creator was removed from the workspace (see Authentication)
403"Missing required permission(s): contacts:update"Token lacks a required scope
404"Contact not found"Contact ID does not exist or belongs to another workspace
409{"detail": "duplicate_contact", "duplicates": [...]}A contact with the same identifier already exists and the effective mode is dont_allow_duplicates (the workspace setting or the request's duplicate_mode). Contains "override_ignored": true when an allow_duplicates override was ignored because the workspace doesn't allow duplicates
409{"error": "CONTACT_BLOCKED", ...}A send to a blocked contact
422{"code": "invalid_contact_owner", ...}owner_user_id is not an active team member
422list of field errorsInvalid fields (e.g., malformed phone number or email)

Next Steps​