Leads API
The people who gave contact details: your CRM's contacts, with custom attributes and site-defined tags. Keyed by email per site. Part of the API reference: setup, shared types and errors are there.
- List contact tags:
GET /contact-tags - Create a contact tag:
POST /contact-tags - Change a contact tag:
PATCH /contact-tags/:id - Delete a contact tag:
DELETE /contact-tags/:id - List leads:
GET /leads - Create a lead:
POST /leads - Get a lead:
GET /leads/:id - Update a lead:
PATCH /leads/:id - Delete a lead:
DELETE /leads/:id - Export leads as CSV:
GET /leads.csv
List contact tags
GET /contact-tags · scope leads:read
The site-defined tags that a contact may carry. Create, rename and remove definitions in Settings → Contact tags; assign them with PATCH /leads/:id.
| Parameter | In | Required | Description |
|---|---|---|---|
site | query | no | The site (a key always uses its own). |
Request
curl "$HELPPUFF_URL/api/v1/contact-tags" \
-H "Authorization: Bearer $HELPPUFF_API_KEY"const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/contact-tags`, {
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
},
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ListContactTagsResponse;Response 200
{
"tags": [
{
"id": "ctag_vip",
"name": "VIP",
"color": "#eab308"
}
],
"colors": [
"#6b7280",
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#3b82f6",
"#8b5cf6",
"#ec4899"
]
}type ListContactTagsResponse = {
tags: Array<{
id: string;
name: string;
color: string;
}>;
colors: string[];
};Create a contact tag
POST /contact-tags · scope settings:write
Up to 100 per site.
| Body field | Required | Description |
|---|---|---|
name | yes | Up to 40 characters, one per name (case does not matter). |
color | no | A hex colour like #eab308; one is picked if left out. |
Request
curl -X POST "$HELPPUFF_URL/api/v1/contact-tags" \
-H "Authorization: Bearer $HELPPUFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "VIP",
"color": "#eab308"
}'type CreateContactTagRequest = {
/** Up to 40 characters, one per name (case does not matter). */
name: string;
/** A hex colour like `#eab308`; one is picked if left out. */
color?: string;
};
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/contact-tags`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'VIP',
color: '#eab308'
} satisfies CreateContactTagRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as CreateContactTagResponse;Response 201
{
"id": "ctag_vip",
"name": "VIP",
"color": "#eab308"
}type CreateContactTagResponse = {
id: string;
name: string;
color: string;
};Errors
| Status | Code | When |
|---|---|---|
| 409 | conflict | A contact tag with this name exists. |
Change a contact tag
PATCH /contact-tags/:id · scope settings:write
Renames or recolours it on every contact that has it.
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | The record's id. |
| Body field | Required | Description |
|---|---|---|
name | no | Up to 40 characters. |
color | no | A hex colour. |
Request
curl -X PATCH "$HELPPUFF_URL/api/v1/contact-tags/ID" \
-H "Authorization: Bearer $HELPPUFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Key account"
}'type ChangeContactTagRequest = {
/** Up to 40 characters. */
name?: string;
/** A hex colour. */
color?: string;
};
const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/contact-tags/${id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Key account'
} satisfies ChangeContactTagRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ChangeContactTagResponse;Response 200
{
"id": "ctag_vip",
"name": "Key account",
"color": "#eab308"
}type ChangeContactTagResponse = {
id: string;
name: string;
color: string;
};Delete a contact tag
DELETE /contact-tags/:id · scope settings:write
Removes it from every contact too.
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | The record's id. |
Request
curl -X DELETE "$HELPPUFF_URL/api/v1/contact-tags/ID" \
-H "Authorization: Bearer $HELPPUFF_API_KEY"const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/contact-tags/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
},
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as DeleteContactTagResponse;Response 200
{
"id": "ctag_vip",
"deleted": true
}type DeleteContactTagResponse = {
id: string;
deleted: boolean;
};List leads
GET /leads · scope leads:read
Most recently updated first, up to 500. Each contact includes its tags. Status and status counts remain for older integrations.
| Parameter | In | Required | Description |
|---|---|---|---|
q | query | no | Search name, email, phone, company, notes and attributes. |
status | query | no | new, contacted, qualified, won or lost. |
tag | query | no | A contact tag id, or untagged. Finds contacts with that tag; a contact may have several. |
site | query | no | The site (a key always uses its own). |
Request
curl "$HELPPUFF_URL/api/v1/leads" \
-H "Authorization: Bearer $HELPPUFF_API_KEY"const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/leads`, {
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
},
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ListLeadsResponse;Response 200
{
"items": [
{
"id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
"site": "acme",
"conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "0400 111 222",
"company": "Analytical Engines",
"address": null,
"fields": "{\"company\":\"Analytical Engines\"}",
"attributes": {
"plan": "pro"
},
"tags": [
{
"id": "ctag_vip",
"name": "VIP",
"color": "#eab308"
}
],
"source": "form",
"status": "new",
"notes": null,
"createdAt": 1760000000000,
"updatedAt": 1760000000000,
"conversations": 2,
"lastConversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
"openCallbacks": 1
}
],
"counts": {
"new": 1
}
}type ListLeadsResponse = {
items: Array<{
id: string;
site: string;
conversationId: string;
name: string;
email: string;
phone: string;
company: string;
address: string | null;
fields: string;
attributes: {
plan: string;
};
tags: Array<{
id: string;
name: string;
color: string;
}>;
source: string;
status: string;
notes: string | null;
createdAt: number;
updatedAt: number;
conversations: number;
lastConversationId: string;
openCallbacks: number;
}>;
counts: Record<string, number>;
};Create a lead
POST /leads · scope leads:write
Adds a contact from elsewhere (your CRM, an import). Needs a name, an email or a phone. One lead per email per site: a second answers 409 with the first one's id. Sends lead.captured with source: "api".
| Body field | Required | Description |
|---|---|---|
name | no | Up to 200 characters. |
email | no | One lead per email per site. |
phone | no | Up to 40 characters. |
company | no | Up to 200 characters. |
address | no | Up to 500 characters. |
attributes | no | Custom attributes: an object of strings (up to 50). |
fields | no | Any other details: up to 20 string values. |
status | no | new (default), contacted, qualified, won or lost. |
notes | no | Up to 5000 characters. |
Request
curl -X POST "$HELPPUFF_URL/api/v1/leads" \
-H "Authorization: Bearer $HELPPUFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Grace Hopper",
"email": "grace@example.com",
"phone": "0400 333 444",
"company": "US Navy",
"attributes": {
"plan": "pro"
},
"status": "new",
"notes": "Met at the expo."
}'type CreateLeadRequest = {
/** Up to 200 characters. */
name?: string;
/** One lead per email per site. */
email?: string;
/** Up to 40 characters. */
phone?: string;
/** Up to 200 characters. */
company?: string;
/** Up to 500 characters. */
address?: string;
/** Custom attributes: an object of strings (up to 50). */
attributes?: {
plan?: string;
};
/** Any other details: up to 20 string values. */
fields?: string;
/** `new` (default), `contacted`, `qualified`, `won` or `lost`. */
status?: string;
/** Up to 5000 characters. */
notes?: string;
};
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/leads`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Grace Hopper',
email: 'grace@example.com',
phone: '0400 333 444',
company: 'US Navy',
attributes: {
plan: 'pro'
},
status: 'new',
notes: 'Met at the expo.'
} satisfies CreateLeadRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as CreateLeadResponse;Response 201
{
"id": "lead_7a1c…",
"site_id": "acme",
"conversation_id": null,
"name": "Grace Hopper",
"email": "grace@example.com",
"phone": "0400 333 444",
"fields": null,
"source": "api",
"status": "new",
"notes": "Met at the expo.",
"created_at": 1760000000000,
"updated_at": 1760000000000,
"company": "US Navy",
"address": null,
"attributes": {
"plan": "pro"
},
"tags": [
{
"id": "ctag_vip",
"name": "VIP",
"color": "#eab308"
}
]
}type CreateLeadResponse = {
id: string;
site_id: string;
conversation_id: string | null;
name: string;
email: string;
phone: string;
fields: string | null;
source: string;
status: string;
notes: string;
created_at: number;
updated_at: number;
company: string;
address: string | null;
attributes: {
plan: string;
};
tags: Array<{
id: string;
name: string;
color: string;
}>;
};Errors
| Status | Code | When |
|---|---|---|
| 409 | conflict | A lead with this email exists (its id is in the message). |
Get a lead
GET /leads/:id · scope leads:read
The contact and its tags, with conversations (status, labels and attributes), the team's dated notes (teamNotes; notes is the contact's own notes field) and callback requests.
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | The record's id. |
Request
curl "$HELPPUFF_URL/api/v1/leads/ID" \
-H "Authorization: Bearer $HELPPUFF_API_KEY"const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/leads/${id}`, {
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
},
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as GetLeadResponse;Response 200
{
"id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
"site_id": "acme",
"conversation_id": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "0400 111 222",
"fields": "{\"company\":\"Analytical Engines\"}",
"source": "form",
"status": "new",
"notes": null,
"created_at": 1760000000000,
"updated_at": 1760000000000,
"company": "Analytical Engines",
"address": null,
"attributes": {
"plan": "pro"
},
"tags": [
{
"id": "ctag_vip",
"name": "VIP",
"color": "#eab308"
}
],
"conversations": [
{
"id": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
"startedAt": 1760000000000,
"lastAt": 1760000120000,
"pageUrl": "https://acme.example/pricing",
"firstMessage": "How much is a blocked drain?",
"messageCount": 6,
"summary": null,
"intent": "Pricing question",
"channel": "widget",
"status": "closed",
"assignedTo": null,
"assignedName": null,
"labels": [
{
"id": "lbl_3f2a1b0c9d8e",
"name": "Urgent",
"color": "#ef4444"
}
],
"attributes": {
"orderId": "A-1042"
}
}
],
"teamNotes": [
{
"id": "note_mfx2k1a9b3c4",
"conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
"leadId": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
"author": "sam@acme.example",
"authorName": "Sam",
"text": "Called her back, booked for Tuesday.",
"createdAt": 1760000000000,
"updatedAt": 1760000000000
}
],
"callbacks": [
{
"id": "cb_mfx2k1a9b3c",
"conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
"leadId": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
"name": "Ada Lovelace",
"phone": "0400 111 222",
"email": "ada@example.com",
"reason": "A quote for two rooms",
"status": "open",
"note": null,
"requestedAt": 1760000000000,
"closedAt": null,
"closedBy": null,
"pageUrl": "https://acme.example/pricing"
}
]
}type GetLeadResponse = {
id: string;
site_id: string;
conversation_id: string;
name: string;
email: string;
phone: string;
fields: string;
source: string;
status: string;
notes: string | null;
created_at: number;
updated_at: number;
company: string;
address: string | null;
attributes: {
plan: string;
};
tags: Array<{
id: string;
name: string;
color: string;
}>;
conversations: Array<{
id: string;
startedAt: number;
lastAt: number;
pageUrl: string;
firstMessage: string;
messageCount: number;
summary: string | null;
intent: string;
channel: string;
status: string;
assignedTo: string | null;
assignedName: string | null;
labels: Array<{
id: string;
name: string;
color: string;
}>;
attributes: {
orderId: string;
};
}>;
teamNotes: Array<{
id: string;
conversationId: string;
leadId: string;
author: string;
authorName: string;
text: string;
createdAt: number;
updatedAt: number;
}>;
callbacks: Array<{
id: string;
conversationId: string;
leadId: string;
name: string;
phone: string;
email: string;
reason: string;
status: string;
note: string | null;
requestedAt: number;
closedAt: number | null;
closedBy: string | null;
pageUrl: string;
}>;
};Update a lead
PATCH /leads/:id · scope leads:write
Changes details, notes, custom attributes or tags. tags replaces the complete list of tag ids; attributes merges: sent keys are set, null removes one. The old status field remains for integrations. Sends lead.updated.
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | The record's id. |
| Body field | Required | Description |
|---|---|---|
status | no | new, contacted, qualified, won or lost. |
name | no | Up to 200 characters. |
email | no | Changes it (still one lead per email per site). |
phone | no | Up to 40 characters; empty or null clears it. |
company | no | Up to 200 characters; empty or null clears it. |
address | no | Up to 500 characters; empty or null clears it. |
attributes | no | An object of strings; null removes a key. |
tags | no | Complete list of contact tag ids from Settings → Contact tags; use [] to remove all tags. |
notes | no | Replaces the notes field (up to 5000 characters). For a dated note, use POST /leads/:id/notes. |
Request
curl -X PATCH "$HELPPUFF_URL/api/v1/leads/ID" \
-H "Authorization: Bearer $HELPPUFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"company": "Analytical Engines",
"tags": [],
"attributes": {
"plan": "pro",
"trial": null
}
}'type UpdateLeadRequest = {
/** `new`, `contacted`, `qualified`, `won` or `lost`. */
status?: string;
/** Up to 200 characters. */
name?: string;
/** Changes it (still one lead per email per site). */
email?: string;
/** Up to 40 characters; empty or null clears it. */
phone?: string;
/** Up to 200 characters; empty or null clears it. */
company?: string;
/** Up to 500 characters; empty or null clears it. */
address?: string;
/** An object of strings; `null` removes a key. */
attributes?: {
plan?: string;
trial?: string | null;
};
/**
* Complete list of contact tag ids from Settings → Contact tags; use `[]` to
* remove all tags.
*/
tags?: string[];
/**
* Replaces the notes field (up to 5000 characters). For a dated note, use
* `POST /leads/:id/notes`.
*/
notes?: string;
};
const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/leads/${id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
company: 'Analytical Engines',
tags: [],
attributes: {
plan: 'pro',
trial: null
}
} satisfies UpdateLeadRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as UpdateLeadResponse;Response 200
{
"id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
"site_id": "acme",
"conversation_id": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "0400 111 222",
"fields": "{\"company\":\"Analytical Engines\"}",
"source": "form",
"status": "contacted",
"notes": null,
"created_at": 1760000000000,
"updated_at": 1760000000000,
"company": "Analytical Engines",
"address": null,
"attributes": {
"plan": "pro"
},
"tags": [
{
"id": "ctag_vip",
"name": "VIP",
"color": "#eab308"
}
]
}type UpdateLeadResponse = {
id: string;
site_id: string;
conversation_id: string;
name: string;
email: string;
phone: string;
fields: string;
source: string;
status: string;
notes: string | null;
created_at: number;
updated_at: number;
company: string;
address: string | null;
attributes: {
plan: string;
};
tags: Array<{
id: string;
name: string;
color: string;
}>;
};Delete a lead
DELETE /leads/:id · scope leads:write
Deletes the lead. With ?erase=conversations, also every conversation linked to it, with their messages and callback requests: for a person who asks to be forgotten.
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | The record's id. |
erase | query | no | conversations to delete the linked conversations too. |
Request
curl -X DELETE "$HELPPUFF_URL/api/v1/leads/ID" \
-H "Authorization: Bearer $HELPPUFF_API_KEY"const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/leads/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
},
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as DeleteLeadResponse;Response 200
{
"id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
"deleted": true,
"conversationsDeleted": 2
}type DeleteLeadResponse = {
id: string;
deleted: boolean;
conversationsDeleted: number;
};Export leads as CSV
GET /leads.csv · scope leads:read
Every lead, newest first, with a tags column. The status column remains for older integrations. Cells that could run as a spreadsheet formula are prefixed with '.
| Parameter | In | Required | Description |
|---|---|---|---|
site | query | no | The site (a key always uses its own). |
Request
curl "$HELPPUFF_URL/api/v1/leads.csv" \
-H "Authorization: Bearer $HELPPUFF_API_KEY"const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/leads.csv`, {
headers: {
Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
},
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data: ExportLeadsAsCSVResponse = await response.text();Response 200
created,name,email,phone,company,address,status,source,notes,attributes,site,conversation,tags
2025-10-09T08:53:20.000Z,Ada Lovelace,ada@example.com,0400 111 222,Analytical Engines,,new,form,,"{""plan"":""pro""}",acme,1b0f6a52-…,/** CSV text. */
type ExportLeadsAsCSVResponse = string;