Skip to main content
A contact is a person your team knows about. A lead is one of that person’s memberships of one campaign. The directory endpoints operate on contacts; the /campaigns/{campaign_id}/leads* endpoints operate on leads.
One contact can have zero, one or many leads. A contact you imported last month and never enrolled anywhere has no lead rows at all — and can still be emailed directly. See Direct Email.
GET /contacts/{contact_id} returns both: the contact’s own fields plus a campaigns array with one entry per lead.

Contact Signal

Every contact carries a derived engagement signal, computed by a periodic rollup job from all of that contact’s campaign memberships. The first matching rule wins:

Pinning a signal

PUT /api/public/v1/contacts/{contact_id}/signal A manual override wins over the derived value unconditionally, so a pinned HOT_LEAD survives every recompute. Send null to clear the pin and return to the derived signal.
signal_override is required as a key — omitting it is a 400, not a no-op. An unrecognised value returns 400 with the allowed list in detail.allowed.
Requires contacts:update.

Searching the Directory

GET /api/public/v1/contacts
All filters AND together. List-valued filters (signal, tag_ids, source_type) OR within themselves. not_in_campaign_id is the useful one for outreach: it gives you everyone matching a filter who is not already being worked, which is how you avoid double-emailing across campaigns.
Requires contacts:read.

Importing Contacts

POST /api/public/v1/contacts/import
mapping is a JSON string in the same direction the campaign upload uses: lead field name → CSV column header. Keys prefixed custom_ land in the contact’s free-form custom_fields.
The direction matters and is the opposite of what the key order suggests. {"email": "email_address"} means “the CSV column called email_address fills the email field” — not the reverse.
Import upserts team-wide: an email already in the directory updates the existing contact rather than creating a duplicate. contacts_created and contacts_matched tell you which happened. Two optional form fields:
Importing creates no campaign membership. Pair it with POST /contacts/bulk/campaigns to enrol imported contacts into a campaign. Requires contacts:create.

Bulk Operations

Four endpoints take a list of contact IDs and apply one change to all of them.
Every bulk call is a per-contact no-op on repeat, so re-running the same request is safe: contact_ids must be a non-empty list of UUIDs and is capped at 1000 entries; anything else is a 400. A tag or list belonging to another team is a 404.
/bulk/lists only accepts STATIC lists. A DYNAMIC list computes its members from its filter, so writing membership to it is meaningless and returns 400 — edit the filter instead.
/bulk/campaigns creates one membership per contact. Contacts already in the campaign are skipped, and addresses blocked in any of the campaign’s workspaces are reported under details.blocked without being enrolled.

Saved Lists

A list is either an explicit set of contacts or a saved filter. A DYNAMIC list picks up newly imported contacts automatically, because nothing is stored about membership at all. POST /api/public/v1/contacts/lists
filter_definition takes exactly the same fields as the GET /contacts query parameters above.
Call GET /contacts/lists/preview with a candidate filter before saving it. It runs the same compiler as the directory search, so the total it returns is exactly what the saved list will match.
list_type is immutable — create a new list to change direction. Creating returns 201, not 200.

Updating and Deleting Contacts

PATCH /api/public/v1/contacts/{contact_id} Only the fields present in the body change. custom_fields merges key-by-key rather than replacing the stored object; pass null for a field to clear it.
Updatable fields: first_name, last_name, company, custom_fields. Requires contacts:update. DELETE /api/public/v1/contacts The body is a plain JSON array of contact IDs — not an object.
Deleting a contact removes it and every campaign membership derived from it, cascading to send-tracking rows attached to those memberships. This cannot be undone.Team-level suppression is keyed by email address rather than by contact, so an existing unsubscribe survives the deletion — re-importing that address will not resurrect the ability to email it.
Requires contacts:delete.

Next Steps

Direct Email

Send a 1:1 email to a contact with no campaign behind it.

Leads

Manage leads inside a specific campaign.

Subsequences

Route engaged leads onto conditional branches.

API Scopes

What each contacts:* scope grants.