Contacts
Contact CRUD, upsert-by-email, search, counts, bulk actions and CSV export. See the Contacts guide.
The Contact object 62 fields
hard, soft, nullCategory-level opt-out state, keyed by email category ID. Source of truth for categorised email types.
Values for fields declared via the custom-fields API. Writing an undeclared key returns UNKNOWN_FIELD.
healthy, watch, at_risk, suppressedThe match key for upserts. Unique per tenant.
Your own record key. Keep it populated so a re-sync can reconcile.
Account-wide sendability. suppressed is set by a soft delete.
active, unsubscribed, bounced, complained, suppressedCanonical contact ID. Store this.
Free-text origin label supplied by the caller.
active, unsubscribed, bounced, complained, suppressed, nullPer-email-type opt-in state. A missing key means subscribed.
The ContactWithIdentityToken object 63 fields
hard, soft, nullCategory-level opt-out state, keyed by email category ID. Source of truth for categorised email types.
Values for fields declared via the custom-fields API. Writing an undeclared key returns UNKNOWN_FIELD.
healthy, watch, at_risk, suppressedThe match key for upserts. Unique per tenant.
Your own record key. Keep it populated so a re-sync can reconcile.
Account-wide sendability. suppressed is set by a soft delete.
active, unsubscribed, bounced, complained, suppressedCanonical contact ID. Store this.
Present only when the request passed ?include_identity_token=true and identity signing is configured. Hand it to the client-side tracking SDK to link anonymous visits to this contact.
Free-text origin label supplied by the caller.
active, unsubscribed, bounced, complained, suppressed, nullPer-email-type opt-in state. A missing key means subscribed.
List contacts
A cursor-paged list of contacts. Page by passing the previous response's next_cursor back as cursor; has_more is false and next_cursor is null on the last page.
The cursor encodes the sort it was created under, so sort and dir must not change between pages — they do not silently re-sort, the request is rejected.
For a full read-out of the audience, prefer POST /api/contacts/export over paging this endpoint: one export produces one CSV instead of thousands of requests against your rate-limit budget.
Opaque next_cursor from the previous page. Omit for the first page.
Page size. Defaults to 50; clamped to 1–200.
Filter by account-wide status. Soft-deleted (suppressed) contacts are excluded unless you ask for them.
Free-text match across name, email and company.
Sort column. One of email, first_name, last_name, company, title, phone, city, state, country, postal_code, source, global_status, deliverability_status, do_not_contact, prospect_score, customer_score, total_emails_sent, total_emails_opened, total_emails_clicked, last_activity_at, last_email_sent_at, last_email_opened_at, last_email_clicked_at, last_page_viewed_at, created_at, updated_at, stage_id. An unrecognised value is not an error — it falls back to created_at.
Sort direction. Defaults to desc.
JSON-encoded array of { field, operator, value } objects. Malformed JSON returns INVALID_FILTERS.
JSON-encoded segment definition, for smart filtering by the same rules a dynamic segment uses. Combined with filters as an intersection.
A page of Contact objects, under data.
A page of contacts, newest first by default.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/contacts" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"data": [
{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth"
}
],
"next_cursor": "eyJpZCI6IjljMjE4OTVkIn0",
"has_more": true
}Create a contact
Creates a contact. Strictly a create: an existing contact with the same email returns 409 DUPLICATE_EMAIL rather than being updated — use PUT /api/contacts when you want create-or-update.
A key in custom_fields must already be declared through the custom-fields API, or the call fails with UNKNOWN_FIELD. smtp_provider and mx_record are resolved by the platform and are not settable.
Return an identity_token for the client-side tracking SDK alongside the contact.
The ContactWithIdentityToken object.
The contact was created.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/contacts" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth",
"company": "Acme",
"phone": "+1 555 0100"
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts', {
method: 'POST',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth",
"company": "Acme",
"phone": "+1 555 0100"
}),
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth"
}Create or update a contact by email
Create-or-update, matched on email. Returns 201 when a contact was created and 200 when an existing one was updated — the status is how you tell which happened.
This is the endpoint an integration should reach for by default: it is idempotent on email, so a replayed sync converges instead of erroring.
On a match, custom_fields and subscriptions are merged key-by-key and tags is unioned with what is already there — an upsert adds tags, it never removes them. Use PUT /api/contacts/{contact_id} when you need tags replaced outright.
Stage handling. stage_id is applied unconditionally, on create and on update. stage_id_if_new is applied only when the contact is being created and is ignored otherwise — use it for signup flows that want a default stage without stamping over a stage a salesperson already set. If both are given, stage_id wins on creation. Changing an existing contact's stage runs the same score-reset logic as a direct update.
Return an identity_token for the client-side tracking SDK alongside the contact.
The ContactWithIdentityToken object.
An existing contact was updated.
A new contact was created.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X PUT "https://api.sonarsend.com/t/acme/api/contacts" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth",
"company": "Acme",
"phone": "+1 555 0100"
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts', {
method: 'PUT',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth",
"company": "Acme",
"phone": "+1 555 0100"
}),
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth"
}Count contacts
How many contacts match a filter, without fetching them. Accepts the same status / search / filters / definition selectors as GET /api/contacts, so you can size an audience before acting on it.
Filter by account-wide status.
Free-text match across name, email and company.
JSON-encoded array of { field, operator, value } objects.
JSON-encoded segment definition, for smart filtering.
The number of matching contacts.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/contacts/count" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/count', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"count": 0
}Get a contact
Fetch one contact by its canonical ID. To look one up by email instead, use GET /api/contacts?search=….
Canonical contact ID.
The Contact object.
The contact.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth"
}Update a contact
Partial update by contact ID — send only the fields you are changing; anything omitted is left alone.
custom_fields and subscriptions are merged key-by-key, so a partial map does not clear the keys it leaves out. tags is the exception — here it replaces the existing array, where the upsert endpoint unions it. Changing stage_id runs the stage transition's score-reset logic. Changing email clears the resolved mx_record / smtp_provider so the new domain is re-enriched. smtp_provider and mx_record are platform-resolved and ignored on write.
Canonical contact ID.
The Contact object.
The updated contact.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X PUT "https://api.sonarsend.com/t/acme/api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth",
"company": "Acme",
"phone": "+1 555 0100"
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80', {
method: 'PUT',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth",
"company": "Acme",
"phone": "+1 555 0100"
}),
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth"
}Soft-delete a contact
A soft delete. The row is kept and its status becomes suppressed, so the contact stops receiving mail and drops out of audiences while its history stays intact for reporting. POST /api/contacts/{contact_id}/restore puts it back in the status it held before.
This is not erasure — for a GDPR request use POST /api/contacts/{contact_id}/anonymize, which scrubs the personal fields irreversibly.
Reach for a status or subscription change before a delete: it is usually what the situation actually calls for, and it is reversible without a second endpoint.
Canonical contact ID.
The contact was suppressed.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X DELETE "https://api.sonarsend.com/t/acme/api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80', {
method: 'DELETE',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();Restore a soft-deleted contact
Undo a soft delete. The contact returns to whatever status it held before it was suppressed — not unconditionally to active, so a contact that had unsubscribed comes back unsubscribed.
Canonical contact ID.
The Contact object.
The restored contact.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80/restore" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80/restore', {
method: 'POST',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth"
}Merge contacts
Folds up to ten duplicates into one surviving contact, then hard-deletes the secondaries — the rows are removed outright, not suppressed, and this cannot be undone. Treat it as a privileged action inside your integration and reserve it for dedupe work you fully control.
field_selections decides, per field, which record's value the survivor keeps. Values are contact IDs, not the words "primary"/"secondary": {"company": "<secondary_id>"} means the survivor takes that contact's company. Naming the primary — or omitting a field — keeps the primary's own value.
Selectable keys are email, first_name, last_name, title, company, phone, address_line1, address_line2, city, state, country, timezone, website, source, external_id, do_not_contact, global_status, stage_id, prospect_score, customer_score, plus subscriptions and custom.<field_key> for one custom field. A custom.<field_key> may also be set to the literal "merge", which unions that field's array values across all the contacts instead of picking one. smtp_provider and mx_record are platform-owned and are re-resolved from the surviving email.
Field name to the ID of the contact whose value wins, e.g. {"company": "<secondary_id>"}. Fields you leave out keep the primary's value.
IDs of static lists the survivor should end up on.
The contact that survives the merge.
IDs of the contacts folded into the primary. One to ten.
The Contact object.
The surviving contact, after the merge.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/contacts/merge" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"primary_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"secondary_ids": [
"string"
],
"field_selections": {},
"list_ids": [
"string"
]
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/merge', {
method: 'POST',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"primary_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"secondary_ids": [
"string"
],
"field_selections": {},
"list_ids": [
"string"
]
}),
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"email": "jamie@example.com",
"first_name": "Jamie",
"last_name": "Reed",
"title": "Head of Growth"
}Apply an action to many contacts
One call that changes many contacts, instead of one request each. If you are hitting the rate limit with per-record writes, this and PUT /api/contacts are the endpoints to move to.
Actions. delete (soft-delete to suppressed), suppress, restore, change_stage (needs stage_id), set_dnc (needs do_not_contact), reset_status, update_field (needs field and value) and enroll_in_sequence (needs enroll_in_sequence_id).
reset_status is not available to API keys and returns 403 ACTION_NOT_AVAILABLE. It clears unsubscribe and do-not-contact state across every targeted contact, which is a consent change we only accept from a signed-in operator. Every other action works normally.
Targeting. Every action except update_field requires an explicit contact_ids array; an empty or absent one returns MISSING_IDS. Only update_field can also target a filter. enroll_in_sequence is capped at 500 contacts per call.
Response shape depends on the action. enroll_in_sequence returns { enrolled, duplicate, skipped }; every other action returns { affected }.
Automation triggers do not fire by default. Pass trigger_automations: true on change_stage or a scalar update_field to emit the per-contact attribute-changed events that attribute_changed sequence entry triggers listen for.
What to do to the targeted contacts.
delete, suppress, restore, change_stage, set_dnc, reset_status, update_field, enroll_in_sequenceExplicit target contact IDs. Required for every action except a filter-targeted update_field.
Required for set_dnc.
Sequence ID. Required for enroll_in_sequence.
Required for update_field. A column name, or custom.<field_key>.
update_field only: target every contact matching this filter instead of an explicit list.
A segment definition, for smart filtering.
Array of { field, operator, value } objects.
update_field on an array field: append to the existing values, or replace them.
add, replaceStage ID. Required for change_stage.
Emit attribute-changed events for contacts that actually changed, so attribute_changed sequence triggers fire. Applies to change_stage and scalar update_field. Defaults to false.
Required for update_field. The value to set.
Contacts changed. Present for every action except enroll_in_sequence.
enroll_in_sequence only: contacts already actively enrolled.
enroll_in_sequence only: contacts newly enrolled.
enroll_in_sequence only: contacts the sequence would not accept.
Counts for the action that ran. enroll_in_sequence reports enrolment counts; every other action reports affected.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/contacts/bulk" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "delete",
"contact_ids": [
"string"
],
"stage_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"do_not_contact": true,
"filter": {
"filters": [
null
],
"definition": null
},
"field": "string"
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/bulk', {
method: 'POST',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"action": "delete",
"contact_ids": [
"string"
],
"stage_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"do_not_contact": true,
"filter": {
"filters": [
null
],
"definition": null
},
"field": "string"
}),
});
const data = await res.json();{
"affected": 0,
"enrolled": 0,
"duplicate": 0,
"skipped": 0
}List recent exports
The 30 most recent exports for the account, newest first — including expired ones, so you can tell "the file is gone" apart from "the export never ran". The download token is never included.
The selectors the export was started with.
Always null for an API-key export — no requester means no notification email.
Set when status is failed.
After this the file is removed and download returns 410 EXPIRED.
The export ID. Same value as export_id on the start response.
Poll until this is completed, then download.
queued, running, completed, failed, expiredRows written. Null until the job finishes.
Recent exports, newest first.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/contacts/export" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/export', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"exports": [
{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"job_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"status": "queued",
"total_contacts": 0,
"config": {},
"error": "string"
}
]
}Start a contact export
Queues an asynchronous export and returns immediately with 202. This is the right way to read the whole audience out — one export produces one CSV, where paging GET /api/contacts would cost thousands of requests against your rate-limit budget.
Poll GET /api/contacts/export/{export_id} until status is completed, then fetch GET /api/contacts/export/{export_id}/download. An export started with an API key has no human requester, so no "ready" email is sent — polling is the only completion signal.
Exports expire. Once past expires_at the file is removed and download returns 410 EXPIRED; re-run the export if you need it again.
Requires contacts:read, not contacts:write: an export extracts data the key can already read and changes nothing.
JSON-encoded segment definition, for smart filtering.
Column allow-list. Omit for the default set.
Array of { field, operator, value } objects.
Include archived contacts. Defaults to false.
Add the lead-score column. Defaults to false.
Add the engagement rollup columns. Defaults to false.
Free-text match across name, email and company.
Restrict to the members of this list or segment.
Restrict to one account-wide contact status.
queuedThe export was queued. Poll the status endpoint with export_id.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/contacts/export" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"segment_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"status": "active",
"search": "string",
"filters": [
null
],
"definition": "string",
"fields": [
"string"
]
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/export', {
method: 'POST',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"segment_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"status": "active",
"search": "string",
"filters": [
null
],
"definition": "string",
"fields": [
"string"
]
}),
});
const data = await res.json();{
"export_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"job_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"status": "queued"
}Get export status
The polling endpoint. Keep calling until status is completed, then download; failed puts the reason in error, and expired means the file is gone and the export has to be re-run.
progress is a live row count while the job runs, and settles to the final total_contacts afterwards.
The export_id from the start response.
The selectors the export was started with.
Always null for an API-key export — no requester means no notification email.
Set when status is failed.
After this the file is removed and download returns 410 EXPIRED.
The export ID. Same value as export_id on the start response.
Rows written so far; the final row count once the job has finished.
Poll until this is completed, then download.
queued, running, completed, failed, expiredRows written. Null until the job finishes.
The export's current state.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/contacts/export/9c21895d-57f0-4a15-a1df-4dc6835d4f80" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/export/9c21895d-57f0-4a15-a1df-4dc6835d4f80', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"job_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"status": "queued",
"total_contacts": 0,
"config": {},
"error": "string"
}Download an export
Streams the finished CSV as text/csv. Not JSON — the body is the file.
409 NOT_READY means the job has not finished; keep polling the status endpoint. 410 EXPIRED means the file is past its TTL and has been removed — re-run the export.
The export_id from the start response.
The export CSV.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/contacts/export/9c21895d-57f0-4a15-a1df-4dc6835d4f80/download" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/contacts/export/9c21895d-57f0-4a15-a1df-4dc6835d4f80/download', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();