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
filefile✅CSV (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": [
    ["Alice Sample","0900000002","0900000003","REF-001","High-value prospect"],
    ["Tran Hoa","0987654321","","REF-002",""]
  ],
  "mapping_suggestions": [
    { "index": 0, "header": "Customer Name",  "sample": "Alice Sample",  "suggested": "full_name" },
    { "index": 1, "header": "Primary Phone",  "sample": "0900000002",  "suggested": "phone" },
    { "index": 2, "header": "Alt. Phone",     "sample": "0900000003",  "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
filefile✅CSV/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": "0900000002", "full_name": "Alice Sample", "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_tokenstring✅Token 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": "0900000002",
      "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": "0900000002",
    "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": "0900000003",
      "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
patchobject✅Fields 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
reasonstring✅DNC 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."] }
}

4.3 Push leads from an external system (Ingest API) ​

Two public endpoints for external systems (CRM, landing pages, marketing platforms...) to push leads into Zorio Telesales campaigns.

Auth: Sanctum Personal Access Token (Bearer) with the ability telesales:push.

Before pushing, you need the dataset_code — the field schema applied to the target campaign. See the Datasets page to fetch dataset_code + schema.

About the campaign_code field ​

campaign_code is a client-supplied identifier used to pick or create the campaign that receives the lead — it is not a code that Zorio generates upfront. Both push endpoints (bulk + single) accept it (nullable, max 64 chars).

ScenarioBehavior
Send campaign_code="X" and the tenant already has a campaign name=XPush into that campaign, auto-attach dataset_id if the campaign has none
Send campaign_code="X" that does not exist yetAuto-create a new campaign name=X, status=draft, dialer_mode=manual bound to the URL dataset
Omit the fieldAuto-create campaign name="API-{YYYYMMDD-HHMMSS}" — not recommended in production because every request spawns a new campaign, making reporting hard

Best practice

  1. Create or pick the target campaign on the /telesales/campaigns UI (or call GET /api/telesales/campaigns to list them).
  2. Use that campaign's name as a fixed campaign_code in every push request of the integration.
  3. If you have several independent lead sources (e.g. landing page A vs CRM webhook B), give each source its own campaign_code so you can track their performance separately.

The response of both endpoints includes a campaign block with id, code, name — code and name are the same value because the telesales_campaigns table has no separate code column; the value mirrors the campaign name.

POST /api/v1/telesales/datasets/{dataset_code}/leads — Push lead from an external system ​

Used by partners to push leads from a CRM or internal system into Zorio. Auth via Sanctum PAT with the ability telesales:push.

http
POST /api/v1/telesales/datasets/sample_life_renewal/leads
Authorization: Bearer <api_token>
Content-Type: application/json

{
  "leads": [
    {
      "full_name": "Alice Sample",
      "phone": "0900000001",
      "lead_data": {
        "policy_number": "POL-2024-0001",
        "annual_premium": 12000000,
        "policy_status": "lapsed"
      }
    },
    {
      "full_name": "Bob Sample",
      "phone": "0900000002",
      "lead_data": {
        "policy_number": "POL-2024-0002",
        "annual_premium": 15000000
      }
    }
  ],
  "campaign_code": "SAMPLE_RENEWAL_202608"
}

Response 200:

json
{
  "ok": true,
  "inserted": 2,
  "skipped_duplicate": 0,
  "skipped_dnc": 0,
  "errors": [],
  "dataset": {
    "id": 5,
    "code": "sample_life_renewal",
    "name": "FinanceCorp Life Insurance — Renewal"
  },
  "campaign": {
    "id": 123,
    "code": "SAMPLE_RENEWAL_202608",
    "created": false
  }
}

The dataset block in the response lets clients confirm which dataset was applied.

Error 404 · dataset_not_found ​

If {dataset_code} in the URL does not match any active dataset of the tenant, the endpoint returns HTTP 404:

json
{ "error": "dataset_not_found" }

Verify the code with GET /api/telesales/datasets first. The sample code sample_life_renewal shown in this documentation is illustrative — replace it with a real dataset code that exists in your tenant.

Validation ​

  • Every lead must include phone (required). Auto-normalizes 0xxx → 84xxx.
  • If provided, lead_data must match the dataset's phase=lead schema:
    • Field not defined → row skipped with errors[i].error = "unknown_field"
    • Required field missing → row skipped with errors[i].error = "missing_required"
    • Wrong data type → row skipped with errors[i].error = "invalid_type"
  • Phone duplicate within the same campaign → skipped_duplicate++
  • Phone in the DNC list → skipped_dnc++
  • Batch limit: 500 leads per request. Split larger batches across multiple requests.

Rate limit ​

  • 60 requests/minute/token
  • Exceeding the quota returns HTTP 429 with a Retry-After header

POST /api/v1/telesales/datasets/{dataset_code}/leads/single — Push a single lead (dedicated) ​

Dedicated endpoint to send one lead per request — designed for realtime use cases (landing-page webhooks, 1-by-1 CRM integrations). Flat payload, REST-ful response with standard HTTP status codes.

http
POST /api/v1/telesales/datasets/maplife_bhnt/leads/single
Authorization: Bearer <api_token>
Content-Type: application/json

{
  "full_name": "Alice Sample",
  "phone": "0900000001",
  "email": "alice@example.com",
  "lead_data": {
    "policy_number": "POL-2024-0001",
    "annual_premium": 12000000
  },
  "campaign_code": "SAMPLE_RENEWAL_202608"
}

Response 201 · Created ​

json
{
  "data": {
    "id": 15401,
    "campaign_id": 123,
    "full_name": "Alice Sample",
    "phone": "0900000001",
    "phone_normalized": "84900000001",
    "email": "alice@example.com",
    "status": "pending",
    "lead_data": {
      "full_name": "Alice Sample",
      "phone": "0900000001",
      "policy_number": "POL-2024-0001",
      "annual_premium": 12000000
    },
    "created_at": "2026-08-16T12:45:00+00:00"
  },
  "dataset": {
    "id": 5,
    "code": "maplife_bhnt",
    "name": "FinanceCorp Life Insurance — Renewal"
  },
  "campaign": {
    "id": 123,
    "code": "SAMPLE_RENEWAL_202608",
    "name": "SAMPLE_RENEWAL_202608",
    "created": false
  }
}

Response 409 · duplicate_phone ​

Phone already exists in this campaign (dedup is per campaign, never cross-campaign):

json
{
  "error": "duplicate_phone",
  "message": "Phone 0900000001 already exists in this campaign",
  "existing_lead_id": 15380
}

Response 403 · phone_in_dnc ​

Phone is in the tenant's Do-Not-Call list:

json
{
  "error": "phone_in_dnc",
  "message": "Phone is in the tenant's Do-Not-Call list"
}

Response 422 · Validation ​

Invalid phone format:

json
{
  "error": "phone_format_invalid",
  "message": "Phone number format is invalid — must be 0xxx or 84xxx"
}

lead_data does not match the dataset schema:

json
{
  "error": "invalid_lead_data",
  "message": "Field 'policy_number' invalid: field_type_mismatch",
  "errors": [
    { "row": 0, "field": "policy_number", "error": "field_type_mismatch", "expected": "text" }
  ]
}

Response 404 · dataset_not_found ​

{dataset_code} does not exist — call GET /api/telesales/datasets for valid codes.

Rate limit (separate bucket) ​

  • 300 requests/minute/token — higher than the bulk endpoint (60/min) because the single-lead flow expects realtime throughput
  • Bucket is independent of the bulk quota — the two endpoints do not share quota
  • On limit exceeded → HTTP 429 with retry_after_sec in the body

When to use /leads/single vs bulk /leads? ​

Use caseRecommended endpoint
Realtime webhook from a landing page → 1 lead immediately/leads/single
Import CSV/Excel → N leads at once/leads (bulk)
Sync from external CRM — one API call per new lead/leads/single
Nightly scheduled job — 500 leads per batch/leads (bulk)

Rule of thumb: if the client has N > 10 leads at once and can wait a few seconds → use bulk. If N = 1 and event-driven → use single.

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