← Docs

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.

Read the API key management guide

How the data flows

The key already identifies the workspace — the partner site must not send a company id.

Partner website
Form submitted → call the API with X-Api-Key
NextX gateway
Looks the key up → resolves the workspace → strips the key
CRM
Dedupes → stores the lead and custom fields → assigns a rep
EnvironmentBase URLUse for
Productionhttps://api.nextx.vnLive traffic
Sandboxhttps://api-dev.nextx.vnDevelopment and testing
A key only works in the environment it was issued for. Test keys cannot call production and vice versa.
1

Get your API key

The CRM administrator creates it and sends it to you.

The key is shown once. Anyone holding it can push data into the workspace — treat it like a password.
2

Test the key before writing code

A workspaceId in the response means the key is valid.

GET/api/v1/crm/integrations/ping
Verify your key
curl https://api.nextx.vn/api/v1/crm/integrations/ping \
  -H "X-Api-Key: YOUR_API_KEY"
Response
{
  "ok": true,
  "workspaceId": "0198a1b2-c3d4-7890-abcd-ef0123456789",
  "serverTime": "2026-07-30T03:12:56+00:00"
}
3

Fetch the field schema

No back-and-forth — this returns the workspace's exact field codes.

GET/api/v1/crm/integrations/schema
Field schema
curl https://api.nextx.vn/api/v1/crm/integrations/schema \
  -H "X-Api-Key: YOUR_API_KEY"
Response
{
  "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"]
}
Custom fields can change at any time. Re-fetch the schema periodically instead of hardcoding the list.
4

Push the lead

Once per submission. Returns contactId and the customer code.

POST/api/v1/crm/integrations/leads
Push a lead
curl -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"
    }
  }'
Response
{
  "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…

FieldTypeRequiredConstraints and notes
fullNamestringYesUp to 255 characters
phonestringphone / emailUp to 50 characters. Only digits are kept and the number is normalised to the local form: 0912…, 84912…, +84912… all become 0912…
emailstringphone / emailUp to 256 characters, must be a valid email address. Email is NOT used for deduplication
sourcestringNoSource 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
externalIdstringNoYour own record id, up to 100 characters. This is the SECOND deduplication layer, after the phone number (see Deduplication)
notestringNoFree-text note, up to 2000 characters
ownerIdUUIDNoPin an owner; leave empty to let the CRM assign one. When given, assigneeId in the result is exactly this value
customFieldsobjectNoKeys are field codes from /schema. Up to 50 fields per request. Wrong type or option → 400
addressstringNoStreet address
citystringNoProvince / city
wardstringNoWard / commune
dobstringNoDate of birth as yyyy-MM-dd or dd/MM/yyyy. If it cannot be read it is ignored, with NO error
genderstringNoMale, Female or Other (case-insensitive). Any other value is ignored
jobTitlestringNoJob title
companyNamestringNoCompany name
createdAtstringNoThe 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
branchCodestringNoCode 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
At least ONE of phone or email is required.
Key permissions. A key that DECLARES scopes needs the right scope per operation: reading (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

FieldMeaning
contactIdThe lead's identifier in the CRM
codeHuman-facing customer code. Examples in this document are illustrative only — treat it as a display string and do not parse it
isNewtrue = newly created · false = already existed, the existing record is returned
matchedBy"external_id" or "phone" when matched; null when newly created
assigneeIdOnly 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

GET/api/v1/crm/integrations/contacts

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

ParameterTypeConstraints and notes
fromDatestringRange start, yyyy-MM-dd or dd/MM/yyyy (Vietnam time). Malformed → 400 INVALID_DATE
toDatestringRange end, same format; a bare date includes the whole day
byUpdatedAtbooleanDefault false = filter by creation date; true = filter by last modification (for incremental sync)
phonestringFilter by phone number; normalised the same way as when pushing a lead
page / pageSizeintegerpage defaults to 1; pageSize defaults to 50, minimum 1, maximum 200 (larger values are clamped to 200)
sortByCustomFieldstringCode of a date-type custom field to sort by (with sortDescending). A code not in the workspace → 400 UNKNOWN_CUSTOM_FIELD
List customers
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"
Response
{
  "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

GET/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.

Get one customer
curl https://api.nextx.vn/api/v1/crm/integrations/contacts/8f3c1d2e-.... \
  -H "X-Api-Key: YOUR_API_KEY"
Response
// 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

PUT/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.

FieldConstraints and notes
fullName, email, address, city, ward, dob, gender, jobTitle, companyNameAny field you send OVERWRITES; fields you omit are kept. This call never deletes data
noteOnly filled when the record has no note yet — it never overwrites a rep's note
customFieldsOverwrites the field codes you send (codes from /schema)
sourceSource name, used when creating or when stamping externalId
externalIdSHOULD 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
createIfMissingDefaults to true: create the customer when missing (an empty name falls back to the phone number). false: return 404 when missing
createdAtOnly used when this call has to create; an existing record keeps its creation date
Update a profile by phone number
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
  }'
Response
{
  "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:

OrderConditionResult
1The phone number already exists in the workspaceReturn the existing customer, isNew = false, matchedBy = phone
2No phone match, but the externalId has been seenReturn the existing customer, isNew = false, matchedBy = external_id
3No matchCreates a new lead, isNew = true
Do send 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.
If an admin turned on the 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.

HTTPMeaningWhat to do
200Success — including the duplicate case (isNew=false)Nothing to do
400Unreadable 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
401Missing or invalid API keyCheck the X-Api-Key header; the key may have been revoked
403A 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
404No matching customer (CONTACT_NOT_FOUND) — GET by id, or PUT by phone with createIfMissing=falseCheck the id / phone; to create when missing, drop createIfMissing=false
409The 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
422Validation failedInspect errors (an object keyed by field name, each value a list of messages) to see which field failed
429Too many requestsBack off and retry: 1s, 2s, 4s
5xxA failure on the NextX sideRetry 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.

ProblemDetails
{
  "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.

JavaScript (Node.js)
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);
PHP
$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.

  • /ping succeeds and the workspaceId matches the intended company.
  • /schema checked 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.