Skip to content

Leads

Endpoints to manage leads within each campaign: 3-step import (preview → dry-run → commit), single CRUD, bulk update / delete / mark DNC, plus a flexible JSON-based custom-field mechanism.

4.1 Importing leads

File format

CSV (UTF-8) or XLSX. There is a generic template CallList_Template.xlsx; industry-specific templates ship in the separate per-customer configuration package.

The flow has three steps:

Step 1 — Preview (inspect column structure)

http
POST /api/telesales/campaigns/{id}/leads/preview
Content-Type: multipart/form-data

file=@call_list.csv

Request form fields (multipart)

FieldTypeRequiredDescriptionValid values
filefileCSV (UTF-8) or XLSX file to previewmax 10MB

Response body fields

FieldTypeDescriptionValid values
headersstring[]Detected header row
samplearray[]Up to 5 sample data rows
mapping_suggestionsobject[]Suggested column → system-field mapping
mapping_suggestions[].indexintegerColumn position (0-based)
mapping_suggestions[].headerstringColumn name
mapping_suggestions[].samplestringSample data
mapping_suggestions[].suggestedstring|nullSuggested system fieldfull_name / phone / email / null
row_countintegerTotal rows (excluding header)

Response 200:

json
{
  "headers": ["Customer Name","Primary Phone","Alt. Phone","Reference Code","Note"],
  "sample": [
    ["Nguyen Mai","0912345678","0988111222","REF-001","High-value prospect"],
    ["Tran Hoa","0987654321","","REF-002",""]
  ],
  "mapping_suggestions": [
    { "index": 0, "header": "Customer Name",  "sample": "Nguyen Mai",  "suggested": "full_name" },
    { "index": 1, "header": "Primary Phone",  "sample": "0912345678",  "suggested": "phone" },
    { "index": 2, "header": "Alt. Phone",     "sample": "0988111222",  "suggested": null },
    { "index": 3, "header": "Reference Code", "sample": "REF-001",     "suggested": null },
    { "index": 4, "header": "Note",           "sample": "High-value",  "suggested": null }
  ],
  "row_count": 487
}

Step 2 — Dry-run (detect duplicates / DNC / errors)

http
POST /api/telesales/campaigns/{id}/leads/dry-run
Content-Type: multipart/form-data

file=@call_list.csv
mapping={"phone":1,"full_name":0,"custom_fields":{"reference_code":3,"alt_phone":2,"note":4}}

mapping format: each value is the 0-based column index in the CSV header row. phone is mandatory (integer). Any additional column can be mapped into custom_fields.* — the key here MUST match a custom-field code registered for entity=Lead.

Request form fields (multipart)

FieldTypeRequiredDescriptionValid values
filefileCSV/XLSX file to dry-runmax 10MB
mappingstring (JSON)Column → system-field mappingJSON object {phone, full_name?, email?, custom_fields?}

Response body fields

FieldTypeDescriptionValid values
file_tokenstringTemporary token identifying the uploaded file — pass back in the commit stepFormat telesales/imports/{campaign_id}/{uuid}.csv
summaryobjectSummary of validation results
summary.totalintegerTotal rows
summary.validintegerValid rows
summary.duplicateintegerDuplicate rows (phone already in the campaign)
summary.dncintegerRows whose phone is on the DNC list
summary.errorsarrayList of format errors
sample_rowsarrayUp to 50 sample rows with classification
sample_rows[].rowintegerRow number in the file (1-based)
sample_rows[].phonestringPhone number
sample_rows[].full_namestringFull name (if mapped)
sample_rows[].statusstringRow classificationvalid / duplicate / dnc / error
sample_rows[].reasonstringReason (only when status is not valid)

Response 200:

json
{
  "file_token": "telesales/imports/36/abc123.csv",
  "summary": {
    "total": 487,
    "valid": 462,
    "duplicate": 18,
    "dnc": 7,
    "errors": []
  },
  "sample_rows": [
    { "row": 1, "phone": "0912345678", "full_name": "Nguyen Mai", "status": "valid" },
    { "row": 5, "phone": "0900000000", "full_name": "Le Linh",    "status": "dnc",
      "reason": "Phone on DNC since 2026-04-01" }
  ]
}

Step 3 — Commit (write to the database)

http
POST /api/telesales/campaigns/{id}/leads/import
Content-Type: multipart/form-data

file_token=telesales/imports/36/abc123.csv
mapping={...same as step 2...}

Request form fields (multipart)

FieldTypeRequiredDescriptionValid values
file_tokenstringToken returned by the dry-run stepStill valid (TTL 24h)
mappingstring (JSON)Column → field mapping — must match the dry-runSame format as step 2

Response body fields

FieldTypeDescriptionValid values
campaign_idintegerCampaign ID
insertedintegerNumber of leads inserted
skipped_duplicateintegerSkipped due to duplication
skipped_dncintegerSkipped due to DNC
skipped_invalidintegerSkipped due to invalid format
took_msintegerProcessing time (ms)

Response 201:

json
{
  "data": {
    "campaign_id": 36,
    "inserted": 462,
    "skipped_duplicate": 18,
    "skipped_dnc": 7,
    "skipped_invalid": 0,
    "took_ms": 1840
  }
}

Declaring custom fields

The system stores extended lead data in the lead's custom-field JSON column. Before importing, the customer's admin must declare valid keys via:

http
GET    /api/custom-fields?entity=Lead
POST   /api/custom-fields
PATCH  /api/custom-fields/{id}
DELETE /api/custom-fields/{id}

Each definition includes entity, code, type (string / integer / decimal / date / datetime / boolean / enum), validation rules, display order, and (optionally) is_encrypted=true to enable the Layer-2 encryption pipeline. Adding a new field requires no schema migration.

4.2 Lead CRUD

EndpointPurpose
GET /api/telesales/campaigns/{id}/leadsList leads (with pagination + filters: ?status=pending&assigned_agent_id=25)
GET /api/telesales/leads/{id}Detail (includes the custom_fields JSON)
PATCH /api/telesales/leads/{id}Update
DELETE /api/telesales/leads/{id}Delete
PATCH /api/telesales/leads/bulkBulk update
DELETE /api/telesales/leads/bulkBulk delete
POST /api/telesales/leads/bulk-dncBulk-push to DNC

GET /api/telesales/campaigns/{id}/leads — list leads

http
GET /api/telesales/campaigns/36/leads?page=1&per_page=50&status=pending&sort=priority_score&dir=desc

Query parameters

FieldTypeRequiredDescriptionValid values
statusstringoptionalFilter by lead statuspending / queued / contacted / converted / dnc / exhausted
assigned_agent_idintegeroptionalFilter by sticky agentUser ID
searchstringoptionalSearch by phone / phone_normalized / full_name / email (LIKE %keyword%)
sortstringoptionalSort columnpriority_score (default) / imported_at / last_attempt_at
dirstringoptionalSort directionasc / desc (default desc)
pageintegeroptionalPage number≥ 1 (default 1)
per_pageintegeroptionalRecords per page1-200 (default 50)

Response fields (each item in data[])

FieldTypeDescriptionValid values
idintegerLead ID
campaign_idintegerCampaign ID
full_namestring|nullLead full name
phonestringPhone number (raw — as entered)
statusstringLead statuspending / queued / contacted / converted / dnc / exhausted
priority_scoreintegerCall priority — higher = called first0-100
assigned_agent_idinteger|nullSticky agent ID
attempts_countintegerNumber of dial attempts
next_attempt_atdatetime|nullScheduled next attempt (ISO 8601 UTC)

Response 200:

json
{
  "data": [
    {
      "id": 78912,
      "campaign_id": 36,
      "full_name": "Nguyen Thi Mai",
      "phone": "0912345678",
      "status": "pending",
      "priority_score": 85,
      "assigned_agent_id": 25,
      "attempts_count": 0,
      "next_attempt_at": "2026-06-07T08:00:00Z"
    }
  ],
  "meta": { "current_page": 1, "last_page": 10, "per_page": 50, "total": 487 }
}

Payload optimization

The list response does not include custom_fields to keep the payload compact (487 leads × 5–10 custom fields is huge). Call GET /api/telesales/leads/{id} when you need full details.

GET /api/telesales/leads/{id} — detail

Response body fields

FieldTypeDescriptionValid values
idintegerLead ID
campaign_idintegerCampaign ID
full_namestring|nullLead full name
phonestringRaw phone number
statusstringLead statuspending / queued / contacted / converted / dnc / exhausted
priority_scoreintegerPriority0-100
next_attempt_atdatetime|nullScheduled next attempt
assigned_agent_idinteger|nullSticky agent
attempts_countintegerAttempts made
custom_fieldsobjectExtended fields — keys match the custom-field definitions (entity Lead)
created_atdatetimeCreation timestamp (ISO 8601 UTC)

Response 200:

json
{
  "data": {
    "id": 78912,
    "campaign_id": 36,
    "full_name": "Nguyen Thi Mai",
    "phone": "0912345678",
    "status": "pending",
    "priority_score": 85,
    "next_attempt_at": "2026-06-07T08:00:00Z",
    "assigned_agent_id": 25,
    "attempts_count": 0,
    "custom_fields": {
      "reference_code": "REF-2026-001",
      "alt_phone": "0988111222",
      "note": "Returning customer, prefers Vietnamese"
    },
    "created_at": "2026-06-06T06:30:00Z"
  }
}

The set of keys inside custom_fields depends on the customer's custom-field definitions for the Lead entity.

Response 404:

json
{ "message": "Lead not found." }

PATCH /api/telesales/leads/{id} — update one lead

Request body fields (every field optional — send only what you want to change)

FieldTypeDescriptionValid values
full_namestringFull namemax 150 chars
emailstringEmailMust be a valid email address
custom_fieldsobjectExtended fields — keys match custom-field definitionsJSON object
priority_scoreintegerPriority0-100
next_attempt_atdatetimeScheduled next attempt (ISO 8601 UTC)
consent_statusstringConsent statusgranted / withdrawn / unknown
consent_sourcestringConsent capture sourcemax 100 chars

Forbidden fields

The following fields cannot be updated through this endpoint: phone, campaign_id, status, assigned_agent_id (use the dedicated re-assignment API).

Body:

json
{
  "full_name": "Nguyen Thi Mai Anh",
  "email": "mai.anh@example.com",
  "custom_fields": {
    "reference_code": "REF-2026-001-UPDATED",
    "note": "Customer requested callback Monday morning"
  },
  "priority_score": 92,
  "next_attempt_at": "2026-06-09T09:00:00Z",
  "consent_status": "granted",
  "consent_source": "phone_call_2026-06-08"
}

Response 200: returns the full lead object (same shape as GET /api/telesales/leads/{id}).

Response 422 — validation cases:

json
// priority_score outside the allowed range
{
  "message": "The given data was invalid.",
  "errors": { "priority_score": ["The priority score must be between 0 and 100."] }
}

// next_attempt_at has invalid format
{
  "message": "The given data was invalid.",
  "errors": { "next_attempt_at": ["The next attempt at is not a valid date."] }
}

// custom_fields is not an array/object
{
  "message": "The given data was invalid.",
  "errors": { "custom_fields": ["The custom fields must be an array."] }
}

// consent_status not in the allowed enum
{
  "message": "The given data was invalid.",
  "errors": { "consent_status": ["The selected consent status is invalid."] }
}

// email is invalid
{
  "message": "The given data was invalid.",
  "errors": { "email": ["The email must be a valid email address."] }
}

DELETE /api/telesales/leads/{id} — delete one lead

Response 204 No Content (success).

Response 404: lead does not exist or does not belong to the account.

Response 422 — lead has an active call:

json
{
  "message": "Cannot delete a lead with an active call.",
  "errors": { "lead_id": ["Lead 78912 is currently on call UUID xxx — wait for it to end first."] }
}

Soft delete

This is a soft delete — the lead's deleted_at is set, but its dial history is preserved. Bulk delete behaves the same way.

PATCH /api/telesales/leads/bulk — bulk update

Request body fields

FieldTypeRequiredDescriptionValid values
lead_idsinteger[]Lead IDs to update1-1000 items
patchobjectFields to apply to all leads in the listSee patch.*
patch.statusstringoptionalNew statuspending / queued / contacted / converted / dnc / exhausted
patch.assigned_agent_idinteger|nulloptionalSticky agentUser ID or null to unassign
patch.priority_scoreintegeroptionalPriority0-100
patch.next_attempt_atdatetimeoptionalScheduled callback timeISO 8601 UTC

Body:

json
{
  "lead_ids": [78912, 78913, 78920, 78921],
  "patch": {
    "status": "queued",
    "assigned_agent_id": 25,
    "priority_score": 100
  }
}

Response body fields

FieldTypeDescriptionValid values
successboolOverall successtrue / false
updatedintegerLeads actually updated
skippedintegerLeads skipped (not in account, or validation failed)

Response 200:

json
{
  "success": true,
  "updated": 4,
  "skipped": 0
}

Response 422 — lead_ids empty or too large:

json
{
  "message": "The given data was invalid.",
  "errors": { "lead_ids": ["The lead ids must have at least 1 item.", "The lead ids may not have more than 1000 items."] }
}

Bulk limits

Maximum 1000 lead_ids per request. Leads not in the account are silently skipped (counted as skipped).

DELETE /api/telesales/leads/bulk — bulk delete

Body:

json
{ "lead_ids": [78912, 78913, 78920] }

Response 200:

json
{ "success": true, "deleted": 3 }

Response 422: validation behaves like PATCH /api/telesales/leads/bulk.

POST /api/telesales/leads/bulk-dnc — bulk-mark DNC

Push many leads onto the DNC list in a single request.

Request body fields

FieldTypeRequiredDescriptionValid values
lead_idsinteger[]Lead IDs to push to DNC1-1000 items
reasonstringDNC reason — required for the audit trailmax 500 chars

Body:

json
{
  "lead_ids": [78912, 78913],
  "reason": "Customer requested opt-out"
}

Response body fields

FieldTypeDescriptionValid values
successboolOverall successtrue / false
added_to_dncintegerPhones newly added to DNC
skipped_already_dncintegerPhones already on DNC
audit_trail_idstringAudit record ID (DNC audit log)Format audit_dnc_*

Response 200:

json
{
  "success": true,
  "added_to_dnc": 2,
  "skipped_already_dnc": 0,
  "audit_trail_id": "audit_dnc_abc123"
}

After marking DNC:

  • The lead's phone number is inserted into the DNC list (source: 'customer_request').
  • The lead's status changes to dnc.
  • Any pending callbacks for the lead are cancelled.
  • An audit record is created in the DNC audit log with actor_id, lead_ids, reason, audit_trail_id.

Response 422 — missing reason:

json
{
  "message": "The given data was invalid.",
  "errors": { "reason": ["The reason field is required."] }
}

Cấp phép theo điều khoản sử dụng của Zorio.