Add Lead to Campaign Endpoint
curl --request POST \
--url https://app.tuco.ai/api/campaigns/{id}/leads \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"leadId": "<string>",
"lead": {
"firstName": "<string>",
"phone": "<string>",
"email": "<string>",
"lastName": "<string>",
"companyName": "<string>",
"jobTitle": "<string>",
"linkedinUrl": "<string>",
"notes": "<string>",
"altPhone1": "<string>",
"altPhone2": "<string>",
"altPhone3": "<string>",
"altEmail1": "<string>",
"altEmail2": "<string>",
"altEmail3": "<string>",
"customFields": {},
"contactOwnerEmail": "<string>",
"contactOwnerId": "<string>",
"ghlContactId": "<string>",
"ghlLocationId": "<string>",
"hsContactId": "<string>"
}
}
'import requests
url = "https://app.tuco.ai/api/campaigns/{id}/leads"
payload = {
"leadId": "<string>",
"lead": {
"firstName": "<string>",
"phone": "<string>",
"email": "<string>",
"lastName": "<string>",
"companyName": "<string>",
"jobTitle": "<string>",
"linkedinUrl": "<string>",
"notes": "<string>",
"altPhone1": "<string>",
"altPhone2": "<string>",
"altPhone3": "<string>",
"altEmail1": "<string>",
"altEmail2": "<string>",
"altEmail3": "<string>",
"customFields": {},
"contactOwnerEmail": "<string>",
"contactOwnerId": "<string>",
"ghlContactId": "<string>",
"ghlLocationId": "<string>",
"hsContactId": "<string>"
}
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
leadId: '<string>',
lead: {
firstName: '<string>',
phone: '<string>',
email: '<string>',
lastName: '<string>',
companyName: '<string>',
jobTitle: '<string>',
linkedinUrl: '<string>',
notes: '<string>',
altPhone1: '<string>',
altPhone2: '<string>',
altPhone3: '<string>',
altEmail1: '<string>',
altEmail2: '<string>',
altEmail3: '<string>',
customFields: {},
contactOwnerEmail: '<string>',
contactOwnerId: '<string>',
ghlContactId: '<string>',
ghlLocationId: '<string>',
hsContactId: '<string>'
}
})
};
fetch('https://app.tuco.ai/api/campaigns/{id}/leads', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.tuco.ai/api/campaigns/{id}/leads",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'leadId' => '<string>',
'lead' => [
'firstName' => '<string>',
'phone' => '<string>',
'email' => '<string>',
'lastName' => '<string>',
'companyName' => '<string>',
'jobTitle' => '<string>',
'linkedinUrl' => '<string>',
'notes' => '<string>',
'altPhone1' => '<string>',
'altPhone2' => '<string>',
'altPhone3' => '<string>',
'altEmail1' => '<string>',
'altEmail2' => '<string>',
'altEmail3' => '<string>',
'customFields' => [
],
'contactOwnerEmail' => '<string>',
'contactOwnerId' => '<string>',
'ghlContactId' => '<string>',
'ghlLocationId' => '<string>',
'hsContactId' => '<string>'
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/campaigns/{id}/leads"
payload := strings.NewReader("{\n \"leadId\": \"<string>\",\n \"lead\": {\n \"firstName\": \"<string>\",\n \"phone\": \"<string>\",\n \"email\": \"<string>\",\n \"lastName\": \"<string>\",\n \"companyName\": \"<string>\",\n \"jobTitle\": \"<string>\",\n \"linkedinUrl\": \"<string>\",\n \"notes\": \"<string>\",\n \"altPhone1\": \"<string>\",\n \"altPhone2\": \"<string>\",\n \"altPhone3\": \"<string>\",\n \"altEmail1\": \"<string>\",\n \"altEmail2\": \"<string>\",\n \"altEmail3\": \"<string>\",\n \"customFields\": {},\n \"contactOwnerEmail\": \"<string>\",\n \"contactOwnerId\": \"<string>\",\n \"ghlContactId\": \"<string>\",\n \"ghlLocationId\": \"<string>\",\n \"hsContactId\": \"<string>\"\n }\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.tuco.ai/api/campaigns/{id}/leads")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"leadId\": \"<string>\",\n \"lead\": {\n \"firstName\": \"<string>\",\n \"phone\": \"<string>\",\n \"email\": \"<string>\",\n \"lastName\": \"<string>\",\n \"companyName\": \"<string>\",\n \"jobTitle\": \"<string>\",\n \"linkedinUrl\": \"<string>\",\n \"notes\": \"<string>\",\n \"altPhone1\": \"<string>\",\n \"altPhone2\": \"<string>\",\n \"altPhone3\": \"<string>\",\n \"altEmail1\": \"<string>\",\n \"altEmail2\": \"<string>\",\n \"altEmail3\": \"<string>\",\n \"customFields\": {},\n \"contactOwnerEmail\": \"<string>\",\n \"contactOwnerId\": \"<string>\",\n \"ghlContactId\": \"<string>\",\n \"ghlLocationId\": \"<string>\",\n \"hsContactId\": \"<string>\"\n }\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.tuco.ai/api/campaigns/{id}/leads")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"leadId\": \"<string>\",\n \"lead\": {\n \"firstName\": \"<string>\",\n \"phone\": \"<string>\",\n \"email\": \"<string>\",\n \"lastName\": \"<string>\",\n \"companyName\": \"<string>\",\n \"jobTitle\": \"<string>\",\n \"linkedinUrl\": \"<string>\",\n \"notes\": \"<string>\",\n \"altPhone1\": \"<string>\",\n \"altPhone2\": \"<string>\",\n \"altPhone3\": \"<string>\",\n \"altEmail1\": \"<string>\",\n \"altEmail2\": \"<string>\",\n \"altEmail3\": \"<string>\",\n \"customFields\": {},\n \"contactOwnerEmail\": \"<string>\",\n \"contactOwnerId\": \"<string>\",\n \"ghlContactId\": \"<string>\",\n \"ghlLocationId\": \"<string>\",\n \"hsContactId\": \"<string>\"\n }\n}"
response = http.request(request)
puts response.read_bodyCampaigns
Add Lead to Campaign Endpoint
Add a lead to a specific campaign and start that lead through the sequence.
POST
/
api
/
campaigns
/
{id}
/
leads
Add Lead to Campaign Endpoint
curl --request POST \
--url https://app.tuco.ai/api/campaigns/{id}/leads \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"leadId": "<string>",
"lead": {
"firstName": "<string>",
"phone": "<string>",
"email": "<string>",
"lastName": "<string>",
"companyName": "<string>",
"jobTitle": "<string>",
"linkedinUrl": "<string>",
"notes": "<string>",
"altPhone1": "<string>",
"altPhone2": "<string>",
"altPhone3": "<string>",
"altEmail1": "<string>",
"altEmail2": "<string>",
"altEmail3": "<string>",
"customFields": {},
"contactOwnerEmail": "<string>",
"contactOwnerId": "<string>",
"ghlContactId": "<string>",
"ghlLocationId": "<string>",
"hsContactId": "<string>"
}
}
'import requests
url = "https://app.tuco.ai/api/campaigns/{id}/leads"
payload = {
"leadId": "<string>",
"lead": {
"firstName": "<string>",
"phone": "<string>",
"email": "<string>",
"lastName": "<string>",
"companyName": "<string>",
"jobTitle": "<string>",
"linkedinUrl": "<string>",
"notes": "<string>",
"altPhone1": "<string>",
"altPhone2": "<string>",
"altPhone3": "<string>",
"altEmail1": "<string>",
"altEmail2": "<string>",
"altEmail3": "<string>",
"customFields": {},
"contactOwnerEmail": "<string>",
"contactOwnerId": "<string>",
"ghlContactId": "<string>",
"ghlLocationId": "<string>",
"hsContactId": "<string>"
}
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
leadId: '<string>',
lead: {
firstName: '<string>',
phone: '<string>',
email: '<string>',
lastName: '<string>',
companyName: '<string>',
jobTitle: '<string>',
linkedinUrl: '<string>',
notes: '<string>',
altPhone1: '<string>',
altPhone2: '<string>',
altPhone3: '<string>',
altEmail1: '<string>',
altEmail2: '<string>',
altEmail3: '<string>',
customFields: {},
contactOwnerEmail: '<string>',
contactOwnerId: '<string>',
ghlContactId: '<string>',
ghlLocationId: '<string>',
hsContactId: '<string>'
}
})
};
fetch('https://app.tuco.ai/api/campaigns/{id}/leads', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.tuco.ai/api/campaigns/{id}/leads",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'leadId' => '<string>',
'lead' => [
'firstName' => '<string>',
'phone' => '<string>',
'email' => '<string>',
'lastName' => '<string>',
'companyName' => '<string>',
'jobTitle' => '<string>',
'linkedinUrl' => '<string>',
'notes' => '<string>',
'altPhone1' => '<string>',
'altPhone2' => '<string>',
'altPhone3' => '<string>',
'altEmail1' => '<string>',
'altEmail2' => '<string>',
'altEmail3' => '<string>',
'customFields' => [
],
'contactOwnerEmail' => '<string>',
'contactOwnerId' => '<string>',
'ghlContactId' => '<string>',
'ghlLocationId' => '<string>',
'hsContactId' => '<string>'
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/campaigns/{id}/leads"
payload := strings.NewReader("{\n \"leadId\": \"<string>\",\n \"lead\": {\n \"firstName\": \"<string>\",\n \"phone\": \"<string>\",\n \"email\": \"<string>\",\n \"lastName\": \"<string>\",\n \"companyName\": \"<string>\",\n \"jobTitle\": \"<string>\",\n \"linkedinUrl\": \"<string>\",\n \"notes\": \"<string>\",\n \"altPhone1\": \"<string>\",\n \"altPhone2\": \"<string>\",\n \"altPhone3\": \"<string>\",\n \"altEmail1\": \"<string>\",\n \"altEmail2\": \"<string>\",\n \"altEmail3\": \"<string>\",\n \"customFields\": {},\n \"contactOwnerEmail\": \"<string>\",\n \"contactOwnerId\": \"<string>\",\n \"ghlContactId\": \"<string>\",\n \"ghlLocationId\": \"<string>\",\n \"hsContactId\": \"<string>\"\n }\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.tuco.ai/api/campaigns/{id}/leads")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"leadId\": \"<string>\",\n \"lead\": {\n \"firstName\": \"<string>\",\n \"phone\": \"<string>\",\n \"email\": \"<string>\",\n \"lastName\": \"<string>\",\n \"companyName\": \"<string>\",\n \"jobTitle\": \"<string>\",\n \"linkedinUrl\": \"<string>\",\n \"notes\": \"<string>\",\n \"altPhone1\": \"<string>\",\n \"altPhone2\": \"<string>\",\n \"altPhone3\": \"<string>\",\n \"altEmail1\": \"<string>\",\n \"altEmail2\": \"<string>\",\n \"altEmail3\": \"<string>\",\n \"customFields\": {},\n \"contactOwnerEmail\": \"<string>\",\n \"contactOwnerId\": \"<string>\",\n \"ghlContactId\": \"<string>\",\n \"ghlLocationId\": \"<string>\",\n \"hsContactId\": \"<string>\"\n }\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.tuco.ai/api/campaigns/{id}/leads")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"leadId\": \"<string>\",\n \"lead\": {\n \"firstName\": \"<string>\",\n \"phone\": \"<string>\",\n \"email\": \"<string>\",\n \"lastName\": \"<string>\",\n \"companyName\": \"<string>\",\n \"jobTitle\": \"<string>\",\n \"linkedinUrl\": \"<string>\",\n \"notes\": \"<string>\",\n \"altPhone1\": \"<string>\",\n \"altPhone2\": \"<string>\",\n \"altPhone3\": \"<string>\",\n \"altEmail1\": \"<string>\",\n \"altEmail2\": \"<string>\",\n \"altEmail3\": \"<string>\",\n \"customFields\": {},\n \"contactOwnerEmail\": \"<string>\",\n \"contactOwnerId\": \"<string>\",\n \"ghlContactId\": \"<string>\",\n \"ghlLocationId\": \"<string>\",\n \"hsContactId\": \"<string>\"\n }\n}"
response = http.request(request)
puts response.read_bodyEndpoint
- Method:
POST - Path:
/api/campaigns/{id}/leads
{id} is the campaign ID in Tuco. Copy the campaign ID from the campaign detail page in the app (copy button next to the ID).
Path parameter
string
required
The campaign id. Copy it from the campaign detail page in the app (the copy button next to
the ID). A campaign that is not in your workspace returns
404.Request body
Send eitherleadId (enrol a lead you already have) or lead (create-or-reuse one).
Sending neither returns 400.
string
An existing lead id. Must belong to the same workspace.
object
Create a new lead — or reuse the matching one — and enrol it. See
Deduplication for what counts as a match.
Show lead fields
Show lead fields
string
required
Required when you send
lead.string
Phone in E.164, e.g.
"+14155551234". Either phone or email is required — prefer phone.string
Email address. Lowercased before dedup.
string
Last name.
string
Company.
string
Job title.
string
LinkedIn profile URL.
string
Free-text notes.
string
Alternate phone 1. Counts for dedup.
string
Alternate phone 2.
string
Alternate phone 3.
string
Alternate email 1. Counts for dedup.
string
Alternate email 2.
string
Alternate email 3.
object
Free-form key/value data stored on the lead.
string
Email of the workspace user who owns this contact.
string
User id of the owner, as an alternative to
contactOwnerEmail.string
GoHighLevel contact id. Used for deterministic dedup and backfilled onto a matched lead.
string
GoHighLevel location id.
string
HubSpot contact id. Used for deterministic dedup.
Quick start (copy-paste example)
Request (existing lead):POST https://app.tuco.ai/api/campaigns/{campaignId}/leads
Content-Type: application/json
Authorization: Bearer <API_KEY>
{
"leadId": "507f1f77bcf86cd799439011"
}
{
"lead": {
"firstName": "Jane",
"lastName": "Doe",
"phone": "+14155551234",
"email": "jane@example.com"
}
}
{
"success": true,
"leadId": "507f1f77bcf86cd799439011",
"campaignId": "507f1f77bcf86cd799439022",
"messageCount": 1,
"message": "Lead added to campaign and sequence started"
}
Use Cases
- Enroll an existing lead into an iMessage campaign from your backend or CRM.
- Create a new lead and add them to a running or scheduled campaign in a single call.
Request Body (Existing Lead)
{
"leadId": "667f1f77bcf86cd799439012"
}
- Validate that the lead belongs to the same workspace.
- Attach the lead to the campaign’s list (if needed).
- Create the campaign messages for that lead based on the campaign’s steps and settings.
Request Body (Create & Add Lead)
{
"lead": {
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"phone": "+12025550000",
"companyName": "Acme Corp",
"jobTitle": "VP Sales"
}
}
lead instead of leadId, Tuco:
- Creates the lead in the same workspace.
- Adds it to the campaign’s underlying list.
- Starts that lead through the campaign sequence.
Response
{
"success": true,
"leadId": "667f1f77bcf86cd799439012",
"campaignId": "6680e6d0bcf86cd799439099",
"messageCount": 3,
"message": "Lead added to campaign and sequence started"
}
200 instead of 201:
{
"success": true,
"leadId": "667f1f77bcf86cd799439012",
"campaignId": "6680e6d0bcf86cd799439099",
"messageCount": 0,
"alreadyInCampaign": true,
"message": "Lead already in campaign (re-entry not allowed)."
}
404. If the payload is invalid (for example, missing both leadId and lead) you receive a 400 with a descriptive error. Authentication failures return 401.
Deduplication
When you sendlead (rather than leadId), Tuco runs a workspace-wide dedup check before creating anything.
Match rule: any of email, phone, altEmail1-3, altPhone1-3 matching an existing lead (after normalization) reuses that lead.
Normalization:
email→ trimmed and lowercasedphone→ E.164 (+15551234567)
firstName, email, phone, companyName, etc.) are NOT applied to it. The existing lead is reused as-is. To update a matched lead, call PATCH /api/leads/{id} separately, then add to the campaign by leadId.
| Existing lead in workspace | Already in this campaign? | Result |
|---|---|---|
| None | — | 201 — new lead created and enrolled |
| Match by phone (E.164) | No | 201 — existing lead enrolled, fields not updated |
| Match by email (lowercased) | No | 201 — existing lead enrolled, fields not updated |
Match by altEmail* / altPhone* | No | 201 — alts count, existing lead enrolled |
| Match by phone or email | Yes | 200 with alreadyInCampaign: true (safe to retry) |
| Phone formatted differently | — | Normalized to E.164 first — (415) 555-1234 matches +14155551234 |
| Email casing differs | — | Lowercased first — A@X.com matches a@x.com |
GHL Workflow Integration (15-minute sync gap)
Tuco’s GHL connector syncs contacts on a 15-minute schedule. To enroll a GHL contact into a Tuco campaign immediately, call this endpoint from a GHL workflow Webhook action.How merging works across the sync gap
When you add a lead via this API and the periodic GHL sync later sees the same contact, Tuco merges by phone only (integration-sync.ts).
| Lead state in Tuco | GHL contact has | Sync result |
|---|---|---|
Lead has phone P, no integrationIds.ghlRecordId yet | Same phone P | Merges: sets ghlRecordId on existing lead, updates fields, $addToSet tags |
| Lead has email match but phone differs / is missing | Email match only | No merge — creates a separate GHL-sourced lead (gotcha) |
Lead already has integrationIds.ghlRecordId = X | contact.id = X | Normal upsert: updates fields, merges tags |
| No Tuco lead exists | New contact | Inserts new lead with source = "ghl" |
ghlContactId (and optionally ghlLocationId) in the request body for deterministic linking. The endpoint:
- Adds
integrationIds.ghlRecordIdto the dedup match (a second call with the sameghlContactIdreuses the lead even if phone or email shifted). - Backfills
integrationIds.ghlRecordId/ghlLocationIdonto a matched existing lead when the lead doesn’t have them yet (never overwrites).
ghlContactId, always send phone in E.164 — the next periodic GHL sync (every 15 minutes) merges by phone. Email-only matching at sync time is not supported and will create a duplicate GHL-sourced lead.
The same applies for HubSpot via hsContactId (stored as integrationIds.hubspotRecordId).