/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 engagementsignal, 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
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.
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.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/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.
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.
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.
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.
