Docs
API reference 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 RadarSend.

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
  }
}

full_name is the contact’s name in one piece, as they wrote it. A form’s Full name field sets it (and fills first_name / last_name with a best-guess split); an integration can set it directly. It is independent of the split fields: setting one never changes the others. {{ full_name }} in an email uses it, falling back to first and last name joined.

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, or when this call activates a pending contact that has no stage yet (one a browser identify() created, so a signup flow gets the same stage whichever call runs first). Ignored for any other existing contact. 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).

A stage_id (or stage_id_if_new) must name a stage this account owns. One that does not — deleted, or belonging to another account — is refused with 400 INVALID_FIELD_VALUE, and the message names the offending id so it can be found in whatever stores it. Both cases report the same code because from the caller’s side they are the same fact: the stage you named is not one of yours.

This matters for anything that keeps stage ids in its own configuration (a CMS plugin, a CRM sync). A stale id is a permanent error, not a transient one — retrying will never fix it, so treat INVALID_FIELD_VALUE as a signal to re-resolve the id against GET /api/settings/stages rather than to back off. Clearing a stage with stage_id: null is still accepted.

If neither is provided, the contact gets the account’s default stage — the one flagged is_default in GET /api/settings/stages. It is applied when the contact becomes active, not when the row is created, so:

  • A contact created active (the usual case for an API call) gets it immediately.
  • A pending contact — one created by an anonymous identify() call or an unconfirmed double opt-in — has no stage until it is activated. stage_id is null for those, and that is deliberate: it has no lifecycle position until somebody has vouched for it.

Every stage change is recorded, including this one, so a contact’s stage can always be traced back to what set it.

Linking the visitor’s browser (session_id)#

When the call is made while serving a visitor — they just signed up, logged in, or checked out on your site — pass their tracking session and the same call also links their browser to the contact:

{
  "email": "jamie@example.com",
  "external_id": "wp_232",
  "stage_id_if_new": "d3f1a2b0-...",
  "session_id": "<the visitor's _emts cookie, unchanged>"
}

session_id is the value of the _emts cookie the tracking script sets on your site, read from the visitor’s request by your server and passed through exactly as received. It also works from a queued job: capture the value when the visitor’s request arrives and send it when the job runs.

What it does:

  • The pages this browser viewed before the call — the signup form, the landing page — are attributed to the contact.
  • Everything the browser does after it is attributed too, with no client-side identify() call.
  • The response carries session_link: linked, or already_linked if that session was this contact’s already.
  • If the contact was written but the link itself could not be made, the call still succeeds and session_link is failed. Repeat it with PUT (the upsert converges) to retry the link.

A visitor with no _emts cookie is ordinary — blocked cookies, a first page, a tracking host on another domain. Send "session_id": null (or leave it out) and the contact is written with nothing linked.

This is the recommended way to identify a visitor you know server-side. It is one authenticated call, so the contact is created active (with stage_id_if_new, source and the rest applied as on any create), where a browser identify() for a new address can only create a pending one. Keep the browser-side _qp.identify() for sites with no server of their own.

A session_id the tracking script did not issue for this account — edited, truncated, from another site, or a bare id — is refused with 400 INVALID_SESSION_ID before anything is written, so fix the value and retry the whole call. If a session is already linked to a different contact, the latest call wins, as it does for identify().

Both PUT /api/contacts and POST /api/contacts accept session_id.

Identity token#

Pass ?include_identity_token=true to receive an identity_token field in the response. It is the value the tracking snippet keeps in the visitor’s _emt cookie; the snippet has no call that accepts it, so using it means setting that first-party cookie yourself. If you have the visitor’s _emts cookie, session_id above links the session in the same call and is the simpler route.

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, full_name, title, company, phone, address_line1, address_line2, city, state, postal_code, 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
  • activate_pending — make the pending contacts in the selection active (eligible for sending, counted toward the plan); contacts in any other status are left alone. Needs contact_ids. Activates as many as the plan allows, oldest first: the response carries affected (how many changed) and quota_blocked (how many pending contacts the contact limit held back)
  • 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.

update_field with field: "stage_id" requires a stage id; null is rejected with INVALID_FIELD. There is no bulk “clear the stage” — every active contact has one (see Stage handling). Move contacts to a different stage instead.

A stage change made through change_stage or update_field is recorded per contact, whatever trigger_automations is set to — the record of what moved a contact is not an opt-in.

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
  • CONTACT_ERASED (409): the contact’s personal data was erased in the app. The record is kept only so report totals stay correct, so it can’t be updated, merged, or used to enroll. To add the person back, create a new contact with their email.
  • INVALID_SESSION_ID (400): the session_id on a create or upsert is not a tracking session issued for this account. Nothing was written. Pass the visitor’s _emts cookie value unchanged, or leave session_id out.

Example:

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

Integration guidance#

  • Store RadarSend 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 whose personal data was erased 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 service 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.

Exactly one stage carries is_default: true — where a contact lands when nothing else names a stage (see Stage handling).