SonarSend API Contacts & audience Contacts

Contacts

The contacts API is used for contact CRUD, search, counts, and bulk updates.

Use it when an external CRM, warehouse sync, or internal tool needs to keep people data in sync with SonarSend.

Required scopes#

  • contacts:read for list, get, count, and export operations
  • contacts:write for create, update, delete, merge, and bulk actions

Contact IDs#

Contacts still use their canonical IDs in URLs.

Example:

GET /api/contacts/9c21895d-57f0-4a15-a1df-4dc6835d4f80

Create a contact#

POST /api/contacts

Example:

{
  "email": "jamie@example.com",
  "first_name": "Jamie",
  "last_name": "Reed",
  "company": "Acme",
  "source": "crm-sync",
  "external_id": "crm_12345",
  "custom_fields": {
    "plan": "enterprise",
    "region": "west"
  },
  "subscriptions": {
    "newsletter": true
  }
}

Typical uses:

  • create a new contact from a CRM
  • upsert-like create flows handled in your own integration logic
  • attach custom fields and subscription data at creation time

List contacts#

GET /api/contacts

Supported query parameters:

  • cursor
  • limit
  • status
  • search
  • sort
  • dir
  • filters
  • definition

Example:

GET /api/contacts?limit=50&status=active&search=jamie

Notes:

  • filters must be valid JSON
  • definition can be used for smart filtering based on a segment-like definition

Count contacts#

GET /api/contacts/count

Use this when you need a quick count without fetching a full page of results.

Get one contact#

GET /api/contacts/:contact_id

Upsert a contact#

PUT /api/contacts

Create-or-update a contact matched by email. If no contact with the given email exists, one is created. If a match is found, the existing contact is updated with the provided fields.

Returns 201 on create, 200 on update.

Example:

{
  "email": "jamie@example.com",
  "first_name": "Jamie",
  "source": "signup-form",
  "stage_id": "d3f1a2b0-...",
  "custom_fields": {
    "plan": "starter"
  }
}

Stage handling#

The upsert endpoint accepts two stage fields with different merge behavior:

  • stage_id — always applied, whether the contact is new or existing. Use this when the caller knows the correct stage and wants to set it unconditionally.
  • stage_id_if_new — only applied when the contact is being created. Ignored if the contact already exists. Use this for signup or registration flows where you want a default stage without overwriting a stage that was already set by a sales team or other process.

If both are provided, stage_id takes precedence on creation.

When an upsert changes an existing contact’s stage, the same score-reset logic applies as for direct updates (e.g., prospect-to-customer resets customer score).

Identity token#

Pass ?include_identity_token=true to receive an identity_token field in the response. This token can be handed to the client-side tracking SDK to link anonymous visits to the contact.

Update a contact#

PUT /api/contacts/:contact_id

You can update any subset of the contact fields, including:

  • profile fields such as name, company, and phone
  • stage_id
  • external_id
  • do_not_contact
  • custom_fields
  • subscriptions

Delete a contact#

DELETE /api/contacts/:contact_id

Only use direct deletes if your integration really owns contact lifecycle. In many cases, updating status or subscription state is safer than deleting records.

Merge contacts#

POST /api/contacts/merge

Example:

{
  "primary_id": "2ec8d707-7b2d-451f-b5ec-e4f041d2b844",
  "secondary_ids": [
    "39de8127-0e65-4e72-b344-b9017608fe35"
  ],
  "field_selections": {
    "company": "39de8127-0e65-4e72-b344-b9017608fe35"
  },
  "list_ids": []
}

field_selections values are contact IDs, not the words "primary" / "secondary". The example above means “the survivor takes the secondary’s company”. Naming the primary, or leaving a field out, keeps the primary’s own value — so an unrecognised value is not an error, it just quietly changes nothing.

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 a single custom field. A custom.<field_key> may also be set to the literal "merge", which unions that field’s array values across every contact in the merge instead of picking one.

Use this carefully. The secondaries are hard-deleted — the rows are removed, not suppressed, and there is no undo. Merge is a destructive reconciliation action and is best reserved for dedupe workflows you fully control.

Bulk actions#

POST /api/contacts/bulk

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.

Supported actions:

  • delete — soft-delete to suppressed
  • suppress
  • restore
  • change_stage — needs stage_id
  • set_dnc — needs do_not_contact
  • update_field — needs field and value
  • enroll_in_sequence — needs enroll_in_sequence_id, capped at 500 contacts per call

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 returns { enrolled, duplicate, skipped }; every other action returns { affected }.

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.

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.

Exporting contacts#

For bulk read-out, use an export rather than paging GET /api/contacts. An export runs asynchronously and produces a single CSV.

POST /api/contacts/export     # start one  -> 202 { export_id, status }
GET  /api/contacts/export     # recent exports
GET  /api/contacts/export/{export_id}            # status
GET  /api/contacts/export/{export_id}/download   # the CSV

All four require contacts:read — an export extracts data your key can already read and changes nothing.

The body of POST /export accepts the same audience selectors as the contact list (segment_id, status, search, filters, definition) plus:

FieldNotes
fieldsColumn allow-list. Omit for the default set.
include_rollupsEngagement rollup columns.
include_lead_scoreLead score column.
include_archivedInclude archived contacts.

Poll the status endpoint until status is completed, then download:

EXPORT_ID=$(curl -sX POST "$BASE/api/contacts/export" \
  -H "X-API-Key: $API_KEY" -H 'Content-Type: application/json' \
  -d '{"include_rollups":true}' | jq -r '.export_id')

until [ "$(curl -s "$BASE/api/contacts/export/$EXPORT_ID" \
  -H "X-API-Key: $API_KEY" | jq -r '.status')" = "completed" ]; do sleep 2; done

curl -s "$BASE/api/contacts/export/$EXPORT_ID/download" \
  -H "X-API-Key: $API_KEY" -o contacts.csv

Notes:

  • Exports expire. After the TTL the file is removed and download returns 410 EXPIRED. Fetch it promptly; re-run the export if you need it again.
  • 409 NOT_READY means the job has not finished — keep polling.
  • When a human starts an export from the app they get a “ready” email. An API-key export has no requester, so no email is sent; poll instead.

Importing contacts#

There is no CSV import endpoint, and this is deliberate. The risky part of import is parsing and column mapping — an affordance for a human with a spreadsheet. An integration already holds structured data, so use the validated write paths instead:

  • PUT /api/contacts — upsert by email, one contact
  • POST /api/contacts/bulk — bulk actions over an explicit set or a filter

Both validate per contact and report per-contact errors, which a CSV upload cannot do as precisely.

Error examples#

Common contact errors include:

  • INVALID_EMAIL
  • DUPLICATE_EMAIL
  • UNKNOWN_FIELD
  • INVALID_FIELD_VALUE

Example:

{
  "error": {
    "code": "INVALID_EMAIL",
    "message": "Email address is invalid"
  }
}

Integration guidance#

  • Store SonarSend contact IDs after creation or lookup.
  • Keep your own external record key in external_id when possible.
  • Treat bulk delete and merge as privileged actions in your integration.
  • If the integration only needs reporting or audience selection, do not grant contacts:write.

Restore an archived contact#

POST /api/contacts/{id}/restore

DELETE /api/contacts/{id} archives rather than erases, and this reverses it — the contact returns to the status it held before archiving. Needs contacts:write.

A contact that has been anonymized cannot be restored; the data is gone.

Why a contact is not receiving mail#

The workspace-wide block is the suppression list, and the API reads and writes it. A contact can also be unreachable for narrower reasons — they blocked one of your From addresses, or one sending provider is holding them on its own list — and those are diagnosed on the contact’s page in the app, where the remedy sits next to the finding.

Contact stages#

GET /api/settings/stages

Lifecycle stages (contacts:read). stage_id is settable on a contact, so read this to resolve a stage’s id before assigning it. Creating and editing stages is session-only.