Open API · Lead
Push leads into CRM with an API key
For partner developers. Whenever someone submits a form on your website, send one HTTP request with your API key — the lead lands in NextX CRM immediately and is auto-assigned to a sales rep. No OAuth, no token refresh loop.
How the data flows
The key already identifies the workspace — the partner site must not send a company id.
| Environment | Base URL | Use for |
|---|---|---|
| Production | https://api.nextx.vn | Live traffic |
| Sandbox | https://api-dev.nextx.vn | Development and testing |
Get your API key
The CRM administrator creates it and sends it to you.
Test the key before writing code
A workspaceId in the response means the key is valid.
/api/v1/crm/integrations/pingcurl https://api.nextx.vn/api/v1/crm/integrations/ping \
-H "X-Api-Key: YOUR_API_KEY"{
"ok": true,
"workspaceId": "0198a1b2-c3d4-7890-abcd-ef0123456789",
"serverTime": "2026-07-30T03:12:56+00:00"
}Fetch the field schema
No back-and-forth — this returns the workspace's exact field codes.
/api/v1/crm/integrations/schemacurl https://api.nextx.vn/api/v1/crm/integrations/schema \
-H "X-Api-Key: YOUR_API_KEY"{
"standardFields": [
{ "name": "fullName", "label": "Họ tên", "type": "TEXT", "required": true },
{ "name": "phone", "label": "Số điện thoại", "type": "PHONE", "required": false }
],
"customFields": [
{ "name": "Dong_xe", "label": "Dòng xe", "type": "TEXT", "required": false },
{ "name": "Goi_dich_vu", "label": "Gói dịch vụ", "type": "SELECT",
"required": false, "options": ["Cơ bản", "Nâng cao"] }
],
"sources": ["Website", "Facebook", "Hotline"]
}Push the lead
Once per submission. Returns contactId and the customer code.
/api/v1/crm/integrations/leadscurl -X POST https://api.nextx.vn/api/v1/crm/integrations/leads \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fullName": "Nguyễn Văn A",
"phone": "0912345678",
"email": "a@example.com",
"source": "Website",
"externalId": "WEB-2026-00123",
"note": "Quan tâm gói cao cấp",
"city": "Hà Nội",
"customFields": {
"Dong_xe": "SUV",
"Goi_dich_vu": "Nâng cao"
}
}'{
"contactId": "8f3c1d2e-....",
"code": "CT00123",
"isNew": true,
"matchedBy": null,
"assigneeId": "b21e...."
}Request fields
At least a phone or an email is required. Numbers accept 0912…, 84912…, +84912…
| Field | Type | Required | Constraints and notes |
|---|---|---|---|
fullName | string | Yes | Up to 255 characters |
phone | string | phone / email | Up to 50 characters. Only digits are kept and the number is normalised to the local form: 0912…, 84912…, +84912… all become 0912… |
email | string | phone / email | Up to 256 characters, must be a valid email address. Email is NOT used for deduplication |
source | string | No | Source name, up to 100 characters (e.g. Website). If it matches a workspace source (case-insensitive) that source is attached; otherwise the lead is still saved but with no source attached — see /schema for the list |
externalId | string | No | Your own record id, up to 100 characters. This is the SECOND deduplication layer, after the phone number (see Deduplication) |
note | string | No | Free-text note, up to 2000 characters |
ownerId | UUID | No | Pin an owner; leave empty to let the CRM assign one. When given, assigneeId in the result is exactly this value |
customFields | object | No | Keys are field codes from /schema. Up to 50 fields per request. Wrong type or option → 400 |
address | string | No | Street address |
city | string | No | Province / city |
ward | string | No | Ward / commune |
dob | string | No | Date of birth as yyyy-MM-dd or dd/MM/yyyy. If it cannot be read it is ignored, with NO error |
gender | string | No | Male, Female or Other (case-insensitive). Any other value is ignored |
jobTitle | string | No | Job title |
companyName | string | No | Company name |
createdAt | string | No | The date the customer began with you (yyyy-MM-dd or dd/MM/yyyy, read as Vietnam time; or ISO with time). Applied ONLY when creating, and only if earlier than now and from 1990 on; a malformed date → 400 INVALID_DATE |
branchCode | string | No | Code of the branch that receives the lead (case-insensitive). Applied only when creating; leave empty to let the CRM assign one by the workspace rules. A code not in the workspace → 400 crm.integration.branch_code_not_found — even if the lead already exists |
phone or email is required.GET /contacts) = crm.contacts.view, pushing a lead (POST /leads) = crm.contacts.create, updating by phone (PUT /contacts/by-phone) = crm.contacts.edit, plus crm.contacts.create when createIfMissing=true (the default). A missing scope → 403 crm.integration.scope_required. Keys that declare no scopes still work for this whole API group. Older keys carrying crm.contacts.manage are currently accepted in place of create/edit, but only temporarily — issue new keys with the real codes.Response
| Field | Meaning |
|---|---|
contactId | The lead's identifier in the CRM |
code | Human-facing customer code. Examples in this document are illustrative only — treat it as a display string and do not parse it |
isNew | true = newly created · false = already existed, the existing record is returned |
matchedBy | "external_id" or "phone" when matched; null when newly created |
assigneeId | Only set when isNew=true: the ownerId you sent, or the rep the CRM auto-assigned; null if the workspace has no distribution rule. When isNew=false it is ALWAYS null — that does not mean the customer has no owner |
Reading back and syncing records
The remaining three endpoints of the integrations group: fetch the customers you pushed, view a single customer, and push profile changes by phone number.
List customers
/api/v1/crm/integrations/contactsThe read side of the Open API: fetch back data you pushed, filter by date range and paginate. Results are sorted ascending by the field being filtered, so pagination does not skip rows while data is still arriving. Each item has id, code, lifecycleStage, customFields (keyed by the /schema codes), createdAt, updatedAt and the profile fields shown below.
| Parameter | Type | Constraints and notes |
|---|---|---|
fromDate | string | Range start, yyyy-MM-dd or dd/MM/yyyy (Vietnam time). Malformed → 400 INVALID_DATE |
toDate | string | Range end, same format; a bare date includes the whole day |
byUpdatedAt | boolean | Default false = filter by creation date; true = filter by last modification (for incremental sync) |
phone | string | Filter by phone number; normalised the same way as when pushing a lead |
page / pageSize | integer | page defaults to 1; pageSize defaults to 50, minimum 1, maximum 200 (larger values are clamped to 200) |
sortByCustomField | string | Code of a date-type custom field to sort by (with sortDescending). A code not in the workspace → 400 UNKNOWN_CUSTOM_FIELD |
curl "https://api.nextx.vn/api/v1/crm/integrations/contacts?fromDate=2026-09-01&toDate=2026-09-30&byUpdatedAt=true&pageSize=100" \
-H "X-Api-Key: YOUR_API_KEY"{
"items": [
{
"id": "8f3c1d2e-....",
"code": "CT00123",
"fullName": "Nguyễn Văn A",
"phone": "0912345678",
"email": "a@example.com",
"address": null, "ward": null, "city": "Hà Nội",
"gender": null, "dob": null,
"jobTitle": null, "companyName": null,
"source": "Website",
"note": "Quan tâm gói cao cấp",
"assigneeId": "b21e....",
"lifecycleStage": "Lead",
"customFields": { "Dong_xe": "SUV" },
"createdAt": "2026-09-12T03:00:00+00:00",
"updatedAt": "2026-09-20T08:15:00+00:00"
}
],
"totalCount": 1,
"page": 1,
"pageSize": 100,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false
}Get one customer
/api/v1/crm/integrations/contacts/{id}Use an id from the list or from a lead-push result. Returns exactly the same fields as a list item.
curl https://api.nextx.vn/api/v1/crm/integrations/contacts/8f3c1d2e-.... \
-H "X-Api-Key: YOUR_API_KEY"// Trả đúng một đối tượng có bộ trường như phần tử "items" ở trên.
// Không có khách → 404, errorCode = CONTACT_NOT_FOUND.Update a profile by phone number
/api/v1/crm/integrations/contacts/by-phone/{phone}For partner systems that push changes into the CRM. The phone number is in the path (accepts 0912…, 84912…, +84912…); the request body holds the fields below, all optional.
| Field | Constraints and notes |
|---|---|
fullName, email, address, city, ward, dob, gender, jobTitle, companyName | Any field you send OVERWRITES; fields you omit are kept. This call never deletes data |
note | Only filled when the record has no note yet — it never overwrites a rep's note |
customFields | Overwrites the field codes you send (codes from /schema) |
source | Source name, used when creating or when stamping externalId |
externalId | SHOULD be sent. Unlike POST /leads, this call looks up externalId BEFORE the phone number: a customer who changes number still lands on the same record and the new number is written. The first sync matches by phone and then stamps the id onto the record |
createIfMissing | Defaults to true: create the customer when missing (an empty name falls back to the phone number). false: return 404 when missing |
createdAt | Only used when this call has to create; an existing record keeps its creation date |
curl -X PUT https://api.nextx.vn/api/v1/crm/integrations/contacts/by-phone/0912345678 \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalId": "ACC-0042",
"fullName": "Nguyễn Văn A",
"city": "Đà Nẵng",
"createIfMissing": true
}'{
"contactId": "8f3c1d2e-....",
"code": "CT00123",
"isNew": false,
"updated": true,
"matchedBy": "external_id"
}isNew=true: no record was found so one was just created. updated=false: found, but the data you sent matched what is stored, so nothing was written. matchedBy is "external_id", "phone" or null when created. An unreadable path number → 400 INVALID_PHONE.Duplicate handling — resending is safe
This is a machine-to-machine flow, so resending the same lead is normal: a user double-clicks, your system retries after a network error. The CRM looks for an existing customer in this order:
| Order | Condition | Result |
|---|---|---|
| 1 | The phone number already exists in the workspace | Return the existing customer, isNew = false, matchedBy = phone |
| 2 | No phone match, but the externalId has been seen | Return the existing customer, isNew = false, matchedBy = external_id |
| 3 | No match | Creates a new lead, isNew = true |
externalId with your own record id, but understand its role: the CRM checks the phone number first, so externalId only decides when the phone cannot match — the customer changed number, the form has no number, or the workspace is in Separate mode (below). With neither, every submission is a new lead. Email is not used for deduplication.Separate push mode in the workspace's duplicate rules, the phone step is skipped: two customers sharing a number stay two records, and externalId is the only dedupe key. When a number matches more than one customer, the CRM returns the first of them.Phone numbers are normalised before matching: 84912345678 and +84912345678 both become 0912345678.
Error codes
Errors follow RFC 7807 — use errorCode and title for specifics.
| HTTP | Meaning | What to do |
|---|---|---|
| 200 | Success — including the duplicate case (isNew=false) | Nothing to do |
| 400 | Unreadable data or an invalid value: broken JSON, malformed date (INVALID_DATE), unknown branch code, wrong custom-field code, invalid phone in the path (INVALID_PHONE) | Read errorCode and title in the response, fix the payload and resend |
| 401 | Missing or invalid API key | Check the X-Api-Key header; the key may have been revoked |
| 403 | A scoped key lacks the scope for this operation (crm.integration.scope_required) | Grant the scope: read = crm.contacts.view, push a lead = crm.contacts.create, update by phone = crm.contacts.edit (plus crm.contacts.create when createIfMissing=true, the default). Keys with no scopes still work for all three |
| 404 | No matching customer (CONTACT_NOT_FOUND) — GET by id, or PUT by phone with createIfMissing=false | Check the id / phone; to create when missing, drop createIfMissing=false |
| 409 | The CRM could not look up the branch list to check branchCode (crm.integration.branch_lookup_failed) | Retry later; if it persists, contact NextX with the traceId |
| 422 | Validation failed | Inspect errors (an object keyed by field name, each value a list of messages) to see which field failed |
| 429 | Too many requests | Back off and retry: 1s, 2s, 4s |
| 5xx | A failure on the NextX side | Retry later; if it persists, contact NextX with the traceId |
Errors returned by the CRM follow RFC 7807 (application/problem+json) and always include a traceId — a 32-character hex string. Log it so the NextX team can find the exact request when you ask for help. Use errorCode (machine-readable) and title (human-readable); the detail field only appears outside production, so do not rely on it.
{
"type": "https://nextx.vn/errors/validation-failed",
"title": "Validation failed: ...",
"status": 422,
"errorCode": "common.validation_failed",
"traceId": "08b16a93df7c4f44ad2031b086fb0351",
"errors": {
"FullName": ["Họ tên không được để trống"],
"": ["Phải có ít nhất số điện thoại hoặc email"]
}
}Code examples
Copy, swap in your key and field names, and it runs.
const res = await fetch(
'https://api.nextx.vn/api/v1/crm/integrations/leads',
{
method: 'POST',
headers: {
'X-Api-Key': process.env.NEXTX_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
fullName: form.name,
phone: form.phone,
email: form.email,
source: 'Website',
externalId: `WEB-${record.id}`,
}),
}
);
const data = await res.json();
if (!res.ok) throw new Error(`${data.errorCode}: ${data.title} (traceId ${data.traceId})`);
console.log(data.contactId, data.isNew);$payload = [
'fullName' => $name,
'phone' => $phone,
'email' => $email,
'source' => 'Website',
'externalId' => 'WEB-' . $recordId,
];
$ch = curl_init('https://api.nextx.vn/api/v1/crm/integrations/leads');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-Api-Key: ' . getenv('NEXTX_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);Go-live checklist
Walk through this before going live with real traffic.
/pingsucceeds and theworkspaceIdmatches the intended company./schemachecked and your payload matches the real custom field codes.- A test lead was pushed on the test environment and is visible to CRM users.
- The same lead resent a second time returns
isNew=false. - The API key lives in a server-side environment variable, not in the browser or your repo.
- Errors are logged together with their
traceId. - Retry logic is in place for 429 and 5xx responses.