Add Lead to Campaign Endpoint
curl --request POST \
--url https://app.tuco.ai/api/campaigns/{id}/leads \
--header 'Authorization: Bearer <token>'import requests
url = "https://app.tuco.ai/api/campaigns/{id}/leads"
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, headers=headers)
print(response.text)const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
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_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/campaigns/{id}/leads"
req, _ := http.NewRequest("POST", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
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>")
.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>'
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>'import requests
url = "https://app.tuco.ai/api/campaigns/{id}/leads"
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, headers=headers)
print(response.text)const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
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_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/campaigns/{id}/leads"
req, _ := http.NewRequest("POST", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
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>")
.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>'
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).
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 PUT /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 first — 9042956129 matches +919876543210 |
| 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).