Skip to content

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 · 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.

ParameterInRequiredDescription
sitequerynoThe site (a key always uses its own).

Request

bash
curl "$HELPPUFF_URL/api/v1/contact-tags" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
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

json
{
  "tags": [
    {
      "id": "ctag_vip",
      "name": "VIP",
      "color": "#eab308"
    }
  ],
  "colors": [
    "#6b7280",
    "#ef4444",
    "#f97316",
    "#eab308",
    "#22c55e",
    "#14b8a6",
    "#3b82f6",
    "#8b5cf6",
    "#ec4899"
  ]
}
ts
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 fieldRequiredDescription
nameyesUp to 40 characters, one per name (case does not matter).
colornoA hex colour like #eab308; one is picked if left out.

Request

bash
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"
}'
ts
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

json
{
  "id": "ctag_vip",
  "name": "VIP",
  "color": "#eab308"
}
ts
type CreateContactTagResponse = {
  id: string;
  name: string;
  color: string;
};

Errors

StatusCodeWhen
409conflictA 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.

ParameterInRequiredDescription
idpathyesThe record's id.
Body fieldRequiredDescription
namenoUp to 40 characters.
colornoA hex colour.

Request

bash
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"
}'
ts
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

json
{
  "id": "ctag_vip",
  "name": "Key account",
  "color": "#eab308"
}
ts
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.

ParameterInRequiredDescription
idpathyesThe record's id.

Request

bash
curl -X DELETE "$HELPPUFF_URL/api/v1/contact-tags/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
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

json
{
  "id": "ctag_vip",
  "deleted": true
}
ts
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.

ParameterInRequiredDescription
qquerynoSearch name, email, phone, company, notes and attributes.
statusquerynonew, contacted, qualified, won or lost.
tagquerynoA contact tag id, or untagged. Finds contacts with that tag; a contact may have several.
sitequerynoThe site (a key always uses its own).

Request

bash
curl "$HELPPUFF_URL/api/v1/leads" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
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

json
{
  "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
  }
}
ts
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 fieldRequiredDescription
namenoUp to 200 characters.
emailnoOne lead per email per site.
phonenoUp to 40 characters.
companynoUp to 200 characters.
addressnoUp to 500 characters.
attributesnoCustom attributes: an object of strings (up to 50).
fieldsnoAny other details: up to 20 string values.
statusnonew (default), contacted, qualified, won or lost.
notesnoUp to 5000 characters.

Request

bash
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."
}'
ts
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

json
{
  "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"
    }
  ]
}
ts
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

StatusCodeWhen
409conflictA 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.

ParameterInRequiredDescription
idpathyesThe record's id.

Request

bash
curl "$HELPPUFF_URL/api/v1/leads/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
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

json
{
  "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"
    }
  ]
}
ts
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.

ParameterInRequiredDescription
idpathyesThe record's id.
Body fieldRequiredDescription
statusnonew, contacted, qualified, won or lost.
namenoUp to 200 characters.
emailnoChanges it (still one lead per email per site).
phonenoUp to 40 characters; empty or null clears it.
companynoUp to 200 characters; empty or null clears it.
addressnoUp to 500 characters; empty or null clears it.
attributesnoAn object of strings; null removes a key.
tagsnoComplete list of contact tag ids from Settings → Contact tags; use [] to remove all tags.
notesnoReplaces the notes field (up to 5000 characters). For a dated note, use POST /leads/:id/notes.

Request

bash
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
  }
}'
ts
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

json
{
  "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"
    }
  ]
}
ts
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.

ParameterInRequiredDescription
idpathyesThe record's id.
erasequerynoconversations to delete the linked conversations too.

Request

bash
curl -X DELETE "$HELPPUFF_URL/api/v1/leads/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
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

json
{
  "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
  "deleted": true,
  "conversationsDeleted": 2
}
ts
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 '.

ParameterInRequiredDescription
sitequerynoThe site (a key always uses its own).

Request

bash
curl "$HELPPUFF_URL/api/v1/leads.csv" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
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

csv
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-…,
ts
/** CSV text. */
type ExportLeadsAsCSVResponse = string;

MIT licensed. Runs in your own Cloudflare account.