English
English
Appearance
English
English
Appearance
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.
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:
POST /api/telesales/campaigns/{id}/leads/preview
Content-Type: multipart/form-data
file=@call_list.csv| Field | Type | Required | Description | Valid values |
|---|---|---|---|---|
file | file | ✅ | CSV (UTF-8) or XLSX file to preview | max 10MB |
| Field | Type | Description | Valid values |
|---|---|---|---|
headers | string[] | Detected header row | |
sample | array[] | Up to 5 sample data rows | |
mapping_suggestions | object[] | Suggested column → system-field mapping | |
mapping_suggestions[].index | integer | Column position (0-based) | |
mapping_suggestions[].header | string | Column name | |
mapping_suggestions[].sample | string | Sample data | |
mapping_suggestions[].suggested | string|null | Suggested system field | full_name / phone / email / null |
row_count | integer | Total rows (excluding header) |
Response 200:
{
"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
}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.
| Field | Type | Required | Description | Valid values |
|---|---|---|---|---|
file | file | ✅ | CSV/XLSX file to dry-run | max 10MB |
mapping | string (JSON) | ✅ | Column → system-field mapping | JSON object {phone, full_name?, email?, custom_fields?} |
| Field | Type | Description | Valid values |
|---|---|---|---|
file_token | string | Temporary token identifying the uploaded file — pass back in the commit step | Format telesales/imports/{campaign_id}/{uuid}.csv |
summary | object | Summary of validation results | |
summary.total | integer | Total rows | |
summary.valid | integer | Valid rows | |
summary.duplicate | integer | Duplicate rows (phone already in the campaign) | |
summary.dnc | integer | Rows whose phone is on the DNC list | |
summary.errors | array | List of format errors | |
sample_rows | array | Up to 50 sample rows with classification | |
sample_rows[].row | integer | Row number in the file (1-based) | |
sample_rows[].phone | string | Phone number | |
sample_rows[].full_name | string | Full name (if mapped) | |
sample_rows[].status | string | Row classification | valid / duplicate / dnc / error |
sample_rows[].reason | string | Reason (only when status is not valid) |
Response 200:
{
"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" }
]
}POST /api/telesales/campaigns/{id}/leads/import
Content-Type: multipart/form-data
file_token=telesales/imports/36/abc123.csv
mapping={...same as step 2...}| Field | Type | Required | Description | Valid values |
|---|---|---|---|---|
file_token | string | ✅ | Token returned by the dry-run step | Still valid (TTL 24h) |
mapping | string (JSON) | ✅ | Column → field mapping — must match the dry-run | Same format as step 2 |
| Field | Type | Description | Valid values |
|---|---|---|---|
campaign_id | integer | Campaign ID | |
inserted | integer | Number of leads inserted | |
skipped_duplicate | integer | Skipped due to duplication | |
skipped_dnc | integer | Skipped due to DNC | |
skipped_invalid | integer | Skipped due to invalid format | |
took_ms | integer | Processing time (ms) |
Response 201:
{
"data": {
"campaign_id": 36,
"inserted": 462,
"skipped_duplicate": 18,
"skipped_dnc": 7,
"skipped_invalid": 0,
"took_ms": 1840
}
}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:
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.
| Endpoint | Purpose |
|---|---|
GET /api/telesales/campaigns/{id}/leads | List 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/bulk | Bulk update |
DELETE /api/telesales/leads/bulk | Bulk delete |
POST /api/telesales/leads/bulk-dnc | Bulk-push to DNC |
GET /api/telesales/campaigns/{id}/leads — list leads GET /api/telesales/campaigns/36/leads?page=1&per_page=50&status=pending&sort=priority_score&dir=desc| Field | Type | Required | Description | Valid values |
|---|---|---|---|---|
status | string | optional | Filter by lead status | pending / queued / contacted / converted / dnc / exhausted |
assigned_agent_id | integer | optional | Filter by sticky agent | User ID |
search | string | optional | Search by phone / phone_normalized / full_name / email (LIKE %keyword%) | |
sort | string | optional | Sort column | priority_score (default) / imported_at / last_attempt_at |
dir | string | optional | Sort direction | asc / desc (default desc) |
page | integer | optional | Page number | ≥ 1 (default 1) |
per_page | integer | optional | Records per page | 1-200 (default 50) |
data[]) | Field | Type | Description | Valid values |
|---|---|---|---|
id | integer | Lead ID | |
campaign_id | integer | Campaign ID | |
full_name | string|null | Lead full name | |
phone | string | Phone number (raw — as entered) | |
status | string | Lead status | pending / queued / contacted / converted / dnc / exhausted |
priority_score | integer | Call priority — higher = called first | 0-100 |
assigned_agent_id | integer|null | Sticky agent ID | |
attempts_count | integer | Number of dial attempts | |
next_attempt_at | datetime|null | Scheduled next attempt (ISO 8601 UTC) |
Response 200:
{
"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 | Field | Type | Description | Valid values |
|---|---|---|---|
id | integer | Lead ID | |
campaign_id | integer | Campaign ID | |
full_name | string|null | Lead full name | |
phone | string | Raw phone number | |
status | string | Lead status | pending / queued / contacted / converted / dnc / exhausted |
priority_score | integer | Priority | 0-100 |
next_attempt_at | datetime|null | Scheduled next attempt | |
assigned_agent_id | integer|null | Sticky agent | |
attempts_count | integer | Attempts made | |
custom_fields | object | Extended fields — keys match the custom-field definitions (entity Lead) | |
created_at | datetime | Creation timestamp (ISO 8601 UTC) |
Response 200:
{
"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:
{ "message": "Lead not found." }PATCH /api/telesales/leads/{id} — update one lead | Field | Type | Description | Valid values |
|---|---|---|---|
full_name | string | Full name | max 150 chars |
email | string | Must be a valid email address | |
custom_fields | object | Extended fields — keys match custom-field definitions | JSON object |
priority_score | integer | Priority | 0-100 |
next_attempt_at | datetime | Scheduled next attempt (ISO 8601 UTC) | |
consent_status | string | Consent status | granted / withdrawn / unknown |
consent_source | string | Consent capture source | max 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:
{
"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:
// 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:
{
"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 | Field | Type | Required | Description | Valid values |
|---|---|---|---|---|
lead_ids | integer[] | ✅ | Lead IDs to update | 1-1000 items |
patch | object | ✅ | Fields to apply to all leads in the list | See patch.* |
patch.status | string | optional | New status | pending / queued / contacted / converted / dnc / exhausted |
patch.assigned_agent_id | integer|null | optional | Sticky agent | User ID or null to unassign |
patch.priority_score | integer | optional | Priority | 0-100 |
patch.next_attempt_at | datetime | optional | Scheduled callback time | ISO 8601 UTC |
Body:
{
"lead_ids": [78912, 78913, 78920, 78921],
"patch": {
"status": "queued",
"assigned_agent_id": 25,
"priority_score": 100
}
}| Field | Type | Description | Valid values |
|---|---|---|---|
success | bool | Overall success | true / false |
updated | integer | Leads actually updated | |
skipped | integer | Leads skipped (not in account, or validation failed) |
Response 200:
{
"success": true,
"updated": 4,
"skipped": 0
}Response 422 — lead_ids empty or too large:
{
"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:
{ "lead_ids": [78912, 78913, 78920] }Response 200:
{ "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.
| Field | Type | Required | Description | Valid values |
|---|---|---|---|---|
lead_ids | integer[] | ✅ | Lead IDs to push to DNC | 1-1000 items |
reason | string | ✅ | DNC reason — required for the audit trail | max 500 chars |
Body:
{
"lead_ids": [78912, 78913],
"reason": "Customer requested opt-out"
}| Field | Type | Description | Valid values |
|---|---|---|---|
success | bool | Overall success | true / false |
added_to_dnc | integer | Phones newly added to DNC | |
skipped_already_dnc | integer | Phones already on DNC | |
audit_trail_id | string | Audit record ID (DNC audit log) | Format audit_dnc_* |
Response 200:
{
"success": true,
"added_to_dnc": 2,
"skipped_already_dnc": 0,
"audit_trail_id": "audit_dnc_abc123"
}After marking DNC:
source: 'customer_request').status changes to dnc.actor_id, lead_ids, reason, audit_trail_id.Response 422 — missing reason:
{
"message": "The given data was invalid.",
"errors": { "reason": ["The reason field is required."] }
}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.
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).
| Scenario | Behavior |
|---|---|
Send campaign_code="X" and the tenant already has a campaign name=X | Push into that campaign, auto-attach dataset_id if the campaign has none |
Send campaign_code="X" that does not exist yet | Auto-create a new campaign name=X, status=draft, dialer_mode=manual bound to the URL dataset |
| Omit the field | Auto-create campaign name="API-{YYYYMMDD-HHMMSS}" — not recommended in production because every request spawns a new campaign, making reporting hard |
Best practice
/telesales/campaigns UI (or call GET /api/telesales/campaigns to list them).name as a fixed campaign_code in every push request of the integration.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.
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:
{
"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.
If {dataset_code} in the URL does not match any active dataset of the tenant, the endpoint returns HTTP 404:
{ "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.
phone (required). Auto-normalizes 0xxx → 84xxx.lead_data must match the dataset's phase=lead schema: errors[i].error = "unknown_field"errors[i].error = "missing_required"errors[i].error = "invalid_type"skipped_duplicate++skipped_dnc++Retry-After headerPOST /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.
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"
}{
"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
}
}Phone already exists in this campaign (dedup is per campaign, never cross-campaign):
{
"error": "duplicate_phone",
"message": "Phone 0900000001 already exists in this campaign",
"existing_lead_id": 15380
}Phone is in the tenant's Do-Not-Call list:
{
"error": "phone_in_dnc",
"message": "Phone is in the tenant's Do-Not-Call list"
}Invalid phone format:
{
"error": "phone_format_invalid",
"message": "Phone number format is invalid — must be 0xxx or 84xxx"
}lead_data does not match the dataset schema:
{
"error": "invalid_lead_data",
"message": "Field 'policy_number' invalid: field_type_mismatch",
"errors": [
{ "row": 0, "field": "policy_number", "error": "field_type_mismatch", "expected": "text" }
]
}{dataset_code} does not exist — call GET /api/telesales/datasets for valid codes.
retry_after_sec in the body/leads/single vs bulk /leads? | Use case | Recommended 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.