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
| Scope | Purpose |
|---|---|
contacts:read | List and view contacts |
contacts:create | Create contacts and import CSV / Excel files |
contacts:update | Update contact details, add tags, block / unblock, bulk actions |
contacts:delete | Delete contacts |
contacts:export | Export contacts to CSV / Excel |
messages:read / messages:delete | Together with contacts:read: list / delete a contact's files |
Key Contact Fields
| Field | Description |
|---|---|
id | Unique contact identifier |
name | Display name |
email | Email address — reflects the contact's primary email contact method (see note below) |
phone | Phone number (E.164 format, e.g., +14155551234) — reflects the contact's primary phone contact method (see note below) |
contact_methods | Array of contact methods (platform IDs like WhatsApp, Telegram, etc.) |
custom_fields | Typed custom field values, keyed by field key (see Custom Fields) |
tags | Array of associated tags |
owner_user_id | ID of the team member who owns the contact (null = no owner). See Contact Owner |
owner | The 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_blocked | true when the contact is blocked |
blocked_inbound_count / last_blocked_inbound_at | How many inbound messages were dropped because the contact is blocked, and when the last one arrived |
phone and email are derived from contact methodscontact_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_type | Value format | channel_id | Notes |
|---|---|---|---|
phone | E.164, e.g. +14155551234 | Not used | Also 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 |
email | Email address | Not used | Carries its own deliverability status |
whatsapp_id | WhatsApp ID without a leading +, e.g. 34612345678; alphanumeric IDs are also accepted | Not used | Workspace-wide. Phone-style formatting (+, spaces, dashes, parentheses, periods) is stripped |
whatsapp_bsuid | WhatsApp Business-scoped user ID, e.g. US.13491208655302741918 | Required | Stored verbatim, periods included. See Migrating to BSUIDs |
telegram_id | Telegram user ID | Not used | Workspace-wide |
messenger_id | Facebook page-scoped ID (PSID) | Required | Only valid for the Page that issued it |
instagram_id | Instagram-scoped ID (IGSID) | Required | Only valid for the account that issued it |
linkedin, homepage, facebook | URL (https:// is prepended when omitted) | Not used | Social profile, not addressable |
instagram_handle | Handle without @ | Not used | Social profile, not addressable |
social_other | Free-form | Not used | display_name is required (e.g. "TikTok") |
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.
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 field | Type | Description |
|---|---|---|
verified | boolean | true when the identifier is confirmed |
verified_at | datetime | null | When it was verified |
verified_source | string | null | channel_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": trueon acontact_methodsitem inPOST /contacts, onPOST /contacts/{id}/methodsor onPUT /contacts/{id}/methods/{method_id}(api). Sending it on an existing unverified method upgrades it; omitting it never removes verification. On PUT,"verified": falseremoves it, and changing thevalueof any method (platform IDs included) resets it to unverified unless"verified": trueis sent in the same request. - CSV import with
identifiers_verified=trueverifies 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.
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
| Parameter | Type | Default | Description |
|---|---|---|---|
search | string | Search by name, phone, email or contact / method ID | |
tag_id | string | Filter by tag ID. Repeatable (?tag_id=a&tag_id=b) to match contacts with any of the tags | |
list_id | string | Filter by list membership. 422 when the list is not in your workspace | |
list_subscription_status | string | subscribed | With 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_channel | string | With list_id: only subscriptions on this channel (email, sms, whatsapp, telegram, messenger, instagram, browser_push) | |
custom_field_id | string | Custom field filter. Alone: contacts with any value in that field | |
custom_field_value | string | With custom_field_id: the value to compare. Text fields match as "contains"; select fields need an exact option (422 otherwise) | |
custom_field_op | string | eq, 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_to | string | Upper bound for between | |
is_blocked | boolean | true: only blocked contacts, false: only unblocked. Omit for both | |
include_live_chat | boolean | false | Include anonymous Live Chat visitors (contacts that only have a live chat identifier) |
include | string | Comma-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_id | string | Contact 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_by | string | created_at | created_at, name, email or custom_field (needs custom_field_id) |
sort_dir | string | desc | asc or desc |
page | integer | 1 | Page number |
page_size | integer | 20 | Items 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Display name |
email | string | No | Email address |
phone | string | No | Phone number in E.164 format |
contact_methods | array | No | Array 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_fields | object | No | Typed 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_id | string | No | Contact owner: user ID of an active team member |
tag_ids | string[] | No | Contact 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 sendstag_idswithout it is refused with403,"code": "insufficient_scope"and"missing_scopes": ["contacts:update"]; nothing is created. Requests withouttag_idsonly needcontacts: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 intag_ids; nothing is written. - Events: each tag fires
contact.tag_added(webhook and flow trigger), exactly like the single-tag endpoint.contact.createdis sent first, so a flow started bycontact.createddoes not see the tags yet; use the tag trigger for tag-based automation. - Response: the
tagsarray of the201response 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 ascustom_fields); otherwise they are skipped and the201still 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_idandowner. -
Filter:
GET /contacts?owner_user_id=<user_id>|me|none.POST /contacts/exportaccepts the same filter. -
Set or clear: send
owner_user_idonPOST /contacts,PUTorPATCH /contacts/{contact_id};nullremoves the owner. The owner must be an active member of the workspace, otherwise the request fails with422and 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 /contactsmerges into an existing contact (see Duplicates & Merging), the existing contact only takes the owner if it has none yet and your token also holdscontacts:update. An existing owner is never replaced by a create. -
Webhook:
contact.updatedcarriesowner_user_idinsidedata.contactand 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) orwhatsapp_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 isfalsefor reusable library files: template or interactive header media, files saved in the Media library, and files reused by other messages, campaigns or templates.urlandthumbnail_urlare signed links that expire after about 60 minutes. Fetch the page again for fresh links.- Pagination is keyed on messages:
page_sizebounds the messages per page, soitemscan be longer thanpage_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..."}
| Status | Meaning |
|---|---|
200 | Deleted |
404 | The 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: trueand an emptyattachmentslist onGET /api/v1/messagesandGET /api/v1/messages/{id}. Show a "file deleted" placeholder. - For a WhatsApp Flow file, the entry in
meta.flow_response.mediakeepsmedia_id,file_nameandmime_typebut changes to"status": "deleted"and"attachment_id": null.response_datais 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_countandlast_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:
- Preview returns how many contacts would change, a small sample, and a
confirmation_token(valid about 10 minutes). - 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
mode | Body |
|---|---|
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)
type | Fields |
|---|---|
set_field | field (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_field | key, value |
add_tags / remove_tags | tags: tag IDs |
add_to_list | list_id, channel_type (email default, sms, whatsapp). Contacts that already have a subscription row on that list, in any status, are skipped |
set_blocked | blocked: true / false (same effect as blocking) |
set_owner | owner_user_id (or null to clear), propagate (default true: also hand open, unassigned conversations to the new owner) |
Errors (in detail.code)
| Status | code | Meaning |
|---|---|---|
400 | TOKEN_INVALID / TOKEN_EXPIRED | Run the preview again |
403 | FILTER_REQUIRES_CONTACTS_READ | A filter selection needs contacts:read |
409 | PAYLOAD_MISMATCH | The execute body differs from the previewed one |
409 | AFFECTED_COUNT_DRIFT | The 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 onlyset_owner. Returns{"success": true, "bulk_action_id": "...", "restored": 398, "skipped": 14}; other jobs return422(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
| Parameter | Description |
|---|---|
list_id / list_ids | Add the imported contacts to one list, or several (comma-separated) |
create_subscription | Create list subscriptions for the imported contacts |
optin_confirmed | Confirms you hold consent for the subscriptions you create |
identifiers_verified | true 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)
| Field | Description |
|---|---|
column_mapping | JSON 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_mode | auto_merge, allow_duplicates, dont_allow_duplicates or update_existing. Defaults to the workspace setting (see Duplicates & Merging) |
update_field_mode | With duplicate_mode=update_existing: overwrite (default) or fill_empty (only fill fields that are empty) |
match_priority | Which identifier finds an existing contact first: email (default), phone or whatsapp_id |
subscriptions | JSON array [{"channel_type": "email" | "sms" | "whatsapp", "list_id": "..."}]. Needs optin_confirmed=true (else 400) |
apply_tag_ids | JSON array of tag IDs added to every contact the import creates or updates |
apply_custom_field | JSON object {"field_id": "...", "value": ...} set on every contact the import creates or updates |
delimiter / quotechar | CSV delimiter (, ; tab |) and quote character |
default_phone_region | Country 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
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.
| Endpoint | Purpose |
|---|---|
GET /email-contacts/import/jobs?status=active|all | Your workspace's import jobs, paginated (page_size up to 100) |
POST /email-contacts/import/{job_id}/cancel | Stop 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|xlsx | A 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}/statusreturnsstatus(queued,processing,completed,failed,cancelled),progress_percent,total_contacts,filename,columns,error_message,error_codeand, once completed,download_url,expires_atandexpired. Whenstatusisfailed,error_codeisexport_failedanderror_messageis a fixed, generic text; otherwiseerror_codeisnull. Retry the export.GET /contacts/export/{job_id}/downloadredirects (302) to the file. The link is valid for 60 minutes. Add?redirect=falseto get{"download_url": "...", "filename": "..."}instead.409while the export is still running,404for an unknown job.- Export files are deleted 7 days after the export finishes (
expires_at). After that, the status showsexpired: truewithout adownload_url, and the download endpoint returns410 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.
| Mode | Behavior |
|---|---|
auto_merge | When 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_duplicates | A new contact is always created. The response includes a potential_duplicates array so the client can surface possible matches to the user. |
dont_allow_duplicates | Creates 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.
| Endpoint | Override Field | Location |
|---|---|---|
POST /contacts | duplicate_mode | JSON body |
POST /contacts/{id}/methods | duplicate_mode | JSON body |
POST /email-contacts/import | duplicate_mode | Multipart 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):
| Endpoint | What happens when the override is ignored |
|---|---|
POST /contacts | No match: the contact is created as usual (201). Match: 409 with "override_ignored": true (see below). |
POST /contacts/{id}/methods | A value another contact already has returns the 409 duplicate_contact_method conflict with "override_ignored": true. |
POST /email-contacts/import | Rows 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_atis kept. is_blockedandis_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 OKwith the surviving contact's full body. - An additional
merged_fromobject 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
| Parameter | Type | Default | Description |
|---|---|---|---|
signals | string | phone,email,platform_id | Comma-separated list. Allowed: phone, email, platform_id, name. The name signal is exact match only and prone to false positives. |
page | integer | 1 | Page number |
page_size | integer | 25 | Items 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
| Response | Without 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 match | The 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 201 | id, 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}/verification | id, contact_id, verified. |
POST /contacts/{id}/subscriptions 201 | id, 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/preview | sample 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
| Response | Without contacts:read |
|---|---|
POST /contacts 409 duplicates[] | [{"id": "contact_d4e5f6a7"}] |
POST /contacts 201 potential_duplicates[] | [{"id": "contact_d4e5f6a7"}] |
POST /contacts/{id}/methods 409 | other_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 cancel | Row 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.
| Status | detail | Description |
|---|---|---|
| 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 |
| 422 | list of field errors | Invalid fields (e.g., malformed phone number or email) |
Next Steps
- Tags & Lists -- organize contacts with tags and lists
- Custom Fields -- store custom data on contacts
- Scheduler -- schedule messages and emails to a contact
- Send Text Messages -- send messages to contacts