#Leads API
Status: Available (reading) / Planned (writing).
GET /leadsandGET /leads/{id}work (scopeleads:read). Creating, changing, accepting, scoring, notes and tasks are Planned: calls return404. The lead fields below are the actual ones, as reading returns them (theLeadschema in OpenAPI).
A lead is a person's inquiry that may become a customer: it is tied to a contact, a conversation, a source, a score, and a deal. Through the API, an external CRM creates leads, reads them, changes the status and the owner, accepts a lead into work, leaves notes, and creates tasks.
Access: leads:read for reading; leads:write for writing; leads:score for scoring (paid, see Lead scoring). PII: yes: a contact contains a name, an email, and a phone number; the free text of a lead (title, intent, ai_summary, score.reason, next_action, lost_reason) may carry PII too, so a lead without a contact still counts as a disclosure. Internal staff notes (notes) are never returned. Every disclosure is written to the disclosure log without values (see PII transfer). In the RU cell, an IP allowlist for the integration is required for the leads:* scope, and when connecting you specify the hosting country of the receiving system (the Russian Federation only; otherwise 403 pii_transfer_not_allowed) and the basis for the transfer (PII transfer). Paid: no, except for scoring a lead with our model. Mode: which fields you may change depends on the tenant's CRM mode; see CRM: modes.
#Base URL
| Cell | URL |
|---|---|
| RU | https://formula-cream.pro/api/public/v1 |
| EN | https://wfacademy.org/api/public/v1 |
| ID | https://wfacademy.id/api/public/v1 |
A key works only on its own cell: an RU key on the EN address returns 401 wrong_cell with the correct_base_url field. Headers, the error format, pagination, and limits are in Conventions. Authentication: Keys and scopes. In all examples, the key is deliberately fake (EXAMPLE).
#Endpoints
| Method and path | Scope | What it does |
|---|---|---|
POST /leads | leads:write | Create a lead (idempotent by external_id) |
GET /leads | leads:read | A list of leads with filters |
GET /leads/{id} | leads:read | A lead by platform ID |
GET /leads/by-external/{external_id} | leads:read | A lead by the ID in the external CRM |
PATCH /leads/{id} | leads:write | Change fields; requires If-Match and Idempotency-Key |
POST /leads/{id}/accept | leads:write | Accept a lead into the external CRM |
POST /leads/{id}/score | leads:score | Score a lead with our model (paid) |
PUT /leads/{id}/score | leads:write | Record the external CRM's score (no charge) |
POST /leads/{id}/actions/{action_id}/ack | leads:write | Confirm or reject an agent's action request |
POST /leads/{id}/notes | leads:write | Add a note |
POST /leads/{id}/tasks | leads:write | Create a task |
Status by endpoint: Available — GET /leads and GET /leads/{id}; every other endpoint in the table is Planned.
Scoring, agent actions, and the status map (PUT /crm/status-map, leads:write) are described separately: scoring, agent actions, status map.
#Objects
#Lead
| Field | Type | Description |
|---|---|---|
id | string (uuid) | The lead's ID on the platform |
external_id | string | null | The lead's ID in the external CRM |
external_url | string | null | A link to the card in the external CRM |
version | integer | null | Version for If-Match; null for now (the database has no version column) — use etag |
etag | string | Version tag; equals the ETag header of the card (weak, like W/"…") |
status | string | One of six canonical statuses: new, qualified, proposal, negotiation, won, lost (see the status map). Archive is not a status but the archived flag |
archived | boolean | The lead is archived (a mark on the lead, not a status) |
archived_reason | string | null | The archive reason, for example ai_not_client |
owner | "internal" | "external" | Who owns the status, owner, and outcome (see modes) |
title | string | null | The lead's title |
source | string | null | The source |
assigned_to | string (uuid) | null | The assignee: a tenant staff member only |
value | object | null | The expected amount: {"amount": "15000.00", "currency": "RUB"} |
product_id | string (uuid) | null | The product the lead relates to |
course_slug | string | null | The course |
contact | object | null | The contact (see below) |
conversation_id | string (uuid) | null | The conversation the lead arose from |
attribution | object | null | The visitor's source and path (see below) |
score | object | null | The score: score (0–100), grade (A ≥ 75, B 50–74, C 25–49, D < 25), reason, flags, source (ai, external, manual), model, scored_at; null for an unscored lead (score 0 and no AI traces). For now reason, source and scored_at may be null (Lead scoring) |
flags | object | null | The flags buy_intent, support, supplier (also in score.flags) |
intent | string | null | The intent identified by the agent |
ai_summary | string | null | The conversation summary |
next_action, next_action_at | string | null | The next action and its due time |
lost_reason | string | null | The loss reason |
qualified_at, won_at, lost_at | string (ISO 8601) | null | Outcome timestamps |
last_activity_at | string (ISO 8601) | null | The last activity |
created_at, updated_at | string (ISO 8601) | Created and changed, UTC |
On the platform, a conversation never has two active leads: one conversation, one active lead.
#Contact (PII)
| Field | Type | Description |
|---|---|---|
id | string (uuid) | The contact's ID |
first_name, last_name | string | null | First and last name |
email | string | null | |
phone | string | null | Phone |
tags | array of string | Tags |
geo_country, geo_city | string | null | Geography by the visitor's IP |
#Attribution
| Field | Type | Description |
|---|---|---|
utm_source, utm_medium, utm_campaign | string | null | Campaign tags |
first_page | string | null | The first page of the visit |
referrer | string | null | Where the visitor came from |
Attribution is recorded once; it cannot be changed through the API.
#POST /leads
Creates a lead and, if needed, a contact. A repeated request with the same external_id does not create a second lead but returns the existing one.
Headers: Authorization: Bearer <key>, Idempotency-Key (recommended; a retry with the same external_id and no header does not create a duplicate), Content-Type: application/json.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
external_id | string | yes | Unique within the tenant and integration | "crm-48213" |
external_url | string | no | An absolute https address | "https://crm.example.com/leads/48213" |
contact | object | yes | At least an email or a phone is required | {"first_name":"Anna","email":"[email protected]"} |
title | string | no | Any string | "Course inquiry" |
source | string | no | Any string | "landing" |
value | object | no | amount as a string, currency an ISO 4217 code | {"amount":"15000.00","currency":"RUB"} |
product_id | string (uuid) | no | A tenant product | "8a6f3d10-2b94-4c7e-a1d5-0e9b7c4f2a11" |
attribution | object | no | Attribution fields | {"utm_source":"yandex"} |
status | string | no | One of six canonical statuses; new by default | "new" |
{
"external_id": "crm-48213",
"external_url": "https://crm.example.com/leads/48213",
"contact": { "first_name": "Anna", "email": "[email protected]", "phone": "+79990001122" },
"title": "Course inquiry",
"source": "landing",
"value": { "amount": "15000.00", "currency": "RUB" },
"attribution": { "utm_source": "yandex", "utm_campaign": "spring" }
}
Response: 201 Created for a new lead, 200 OK on a retry with the same external_id. The body is a lead object.
{
"id": "5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b",
"external_id": "crm-48213",
"external_url": "https://crm.example.com/leads/48213",
"version": 1,
"status": "new",
"archived": false,
"owner": "internal",
"title": "Course inquiry",
"source": "landing",
"assigned_to": null,
"value": { "amount": "15000.00", "currency": "RUB" },
"contact": { "id": "0c4a9d52-7e71-4c63-8f0b-2a9c6e1d3b44", "first_name": "Anna", "last_name": null,
"email": "[email protected]", "phone": "+79990001122", "tags": [] },
"score": null,
"flags": null,
"created_at": "2026-10-11T09:30:00Z",
"updated_at": "2026-10-11T09:30:00Z"
}
Errors: codes from the catalog.
| Code | HTTP | When |
|---|---|---|
validation_failed | 422 | Fields failed validation; details in errors[] |
scope_missing | 403 | No leads:write |
ip_not_allowed | 403 | A request from an address outside the key's IP list (in the RU cell the list is required) |
idempotency_conflict | 409 | The same Idempotency-Key with a different body |
rate_limited | 429 | The write limit was exceeded |
A 422 example (format):
{
"type": "https://formula-cream.pro/developers/errors#validation_failed",
"title": "Запрос не прошёл проверку",
"status": 422,
"code": "validation_failed",
"detail": "Один или несколько параметров некорректны.",
"request_id": "req_EXAMPLE0101",
"errors": [
{ "field": "/contact", "code": "required", "message": "Нужна хотя бы почта или телефон." }
]
}
#Examples
curl -X POST https://formula-cream.pro/api/public/v1/leads \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "Content-Type: application/json" \
-d '{"external_id":"crm-48213","contact":{"first_name":"Anna","email":"[email protected]"}}'
# API_KEY=sap_ru_test_EXAMPLE0KEYID_… (an example, not a real key)
const res = await fetch("https://formula-cream.pro/api/public/v1/leads", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
external_id: "crm-48213",
contact: { first_name: "Anna", email: "[email protected]" },
}),
});
const lead = await res.json();
import os, uuid, httpx
r = httpx.post(
"https://formula-cream.pro/api/public/v1/leads",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"external_id": "crm-48213",
"contact": {"first_name": "Anna", "email": "[email protected]"}},
)
lead = r.json()
$ch = curl_init("https://formula-cream.pro/api/public/v1/leads");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("API_KEY"),
"Idempotency-Key: " . bin2hex(random_bytes(16)),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"external_id" => "crm-48213",
"contact" => ["first_name" => "Anna", "email" => "[email protected]"],
], JSON_UNESCAPED_UNICODE),
]);
$lead = json_decode(curl_exec($ch), true);
#GET /leads
Status: Available. Scope
leads:read.
Returns a page of the tenant's leads. Pagination is cursor-based (Conventions): ascending order by (updated_at, id), the cursor lives 24 hours, is signed, and is bound to the cell, tenant, integration, route and set of filters. The response is {data, next_cursor}; an empty page is an honest 200 with data: []. It contains personal data (the contact and the leads' free text): every disclosure is written to the disclosure log, and if the log row cannot be written the answer is 503.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
limit | integer | no | 1 to 100; 25 by default | 50 |
cursor | string | no | The next_cursor value from the previous response; valid for 24 hours | "eyJ1Ijoi…" |
include_deleted | boolean | no | not supported yet: true gives 422 not_supported (deleted leads are not stored) | false |
status | string | no | One of six canonical statuses | qualified |
updated_since | string | no | leads changed not earlier than the moment (inclusive); ISO 8601, a date or a date-time with a zone | 2026-10-01T00:00:00Z |
updated_after | string | no | the same, but strictly after the moment (a "since the last seen" traversal); not allowed together with updated_since | 2026-10-01T00:00:00Z |
source | string | no | exact value of the lead source | website |
archived | boolean | no | true — archived only; by default (false) — non-archived only | false |
The set of filters comes from the design; it may be extended (for example, by assignee or source), and the exact list is fixed in OpenAPI.
{
"data": [ { "id": "5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b", "status": "new", "version": 1 } ],
"next_cursor": "eyJ1IjoiMjAyNi0xMC0xMVQwOTozMDowMFoiLCJpIjoiNWQxYiJ9"
}
curl "https://formula-cream.pro/api/public/v1/leads?status=qualified&limit=50" \
-H "Authorization: Bearer $API_KEY"
const res = await fetch(
"https://formula-cream.pro/api/public/v1/leads?status=qualified&limit=50",
{ headers: { Authorization: `Bearer ${process.env.API_KEY}` } },
);
const { data, next_cursor } = await res.json();
r = httpx.get(
"https://formula-cream.pro/api/public/v1/leads",
params={"status": "qualified", "limit": 50},
headers={"Authorization": f"Bearer {os.environ['API_KEY']}"},
)
page = r.json()
$ch = curl_init("https://formula-cream.pro/api/public/v1/leads?status=qualified&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("API_KEY")],
]);
$page = json_decode(curl_exec($ch), true);
To receive changes rather than the whole list, request updated_after with the last seen updated_at (or follow next_cursor) and subscribe to lead.updated. There are no tombstones of deleted leads yet. An invalid or expired cursor returns 422 validation_failed with errors[].field = /cursor: start the traversal over.
#GET /leads/{id} and GET /leads/by-external/{external_id}
Status:
GET /leads/{id}— Available (scopeleads:read);GET /leads/by-external/{external_id}— Planned (there is no external CRM yet).
These return a lead object; the ETag header equals the etag field. It contains personal data (the contact and the lead's free text) and is written to the disclosure log before the response; if the write fails the answer is 503. A lead that belongs to someone else, does not exist or has a non-uuid id returns 404 not_found (the platform does not confirm the existence of another tenant's resource).
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
id (path) | string (uuid) | yes | The lead's ID on the platform | 5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b |
external_id (path) | string | yes | The ID in the external CRM; for by-external only | crm-48213 |
curl https://formula-cream.pro/api/public/v1/leads/by-external/crm-48213 \
-H "Authorization: Bearer $API_KEY"
#PATCH /leads/{id}
Changes a lead's fields. The request must carry the version you read, so that two writers do not overwrite each other.
Headers: If-Match: <version> is required; Idempotency-Key is recommended.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
status | string | no | A canonical status | "proposal" |
assigned_to | string (uuid) | no | A staff member of your tenant only | "0c4a9d52-7e71-4c63-8f0b-2a9c6e1d3b44" |
value | object | no | {amount, currency}; amount is a string | {"amount":"15000.00","currency":"RUB"} |
notes | string | no | Any string | "Asked for a call back" |
lost_reason | string | no | For the lost status | "too expensive" |
external_id, external_url | string | no | The ID and link in the external CRM | "crm-48213" |
| Situation | Response |
|---|---|
No If-Match | 428 precondition_required |
| The version is stale | 412 precondition_failed; read the lead again and retry |
The field belongs to the other party (the external mode before or after acceptance, both by the ownership table) | 409 invalid_state; errors[].field holds the path to the field |
| An invalid value (an unknown status, an assignee not from your tenant) | 422 validation_failed |
No leads:write | 403 scope_missing |
curl -X PATCH https://formula-cream.pro/api/public/v1/leads/5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b \
-H "Authorization: Bearer $API_KEY" \
-H "If-Match: 1" -H "Content-Type: application/json" \
-d '{"status":"proposal"}'
await fetch(`https://formula-cream.pro/api/public/v1/leads/${id}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"If-Match": String(lead.version),
"Content-Type": "application/json",
},
body: JSON.stringify({ status: "proposal" }),
});
r = httpx.patch(
f"https://formula-cream.pro/api/public/v1/leads/{lead_id}",
headers={"Authorization": f"Bearer {os.environ['API_KEY']}",
"If-Match": str(lead["version"])},
json={"status": "proposal"},
)
$ch = curl_init("https://formula-cream.pro/api/public/v1/leads/$leadId");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "PATCH",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("API_KEY"),
"If-Match: " . $lead["version"],
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["status" => "proposal"]),
]);
#POST /leads/{id}/accept
Accept a lead into the external CRM (the external mode). After the call, the lead's owner becomes external, and our agents stop their own touches. The request body is optional; it is recommended to pass external_id and external_url if you have not yet saved them through PATCH. The 200 OK response is a lead object. If the lead has already been accepted or the tenant's mode is not external, 409 invalid_state is returned.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
external_id | string | no | The lead's ID in the external CRM | "crm-48213" |
external_url | string | no | An absolute https address | "https://crm.example.com/leads/48213" |
curl -X POST https://formula-cream.pro/api/public/v1/leads/5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b/accept \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 3b241101-e2bb-4255-8caf-4136c566a962" \
-H "Content-Type: application/json" \
-d '{"external_id":"crm-48213"}'
#POST /leads/{id}/notes
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
text | string | yes | A non-empty string | "Asked for a call back after 6 pm" |
The 201 Created response is the created note. Idempotency-Key is recommended.
curl -X POST https://formula-cream.pro/api/public/v1/leads/$LEAD_ID/notes \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"text":"Asked for a call back after 6 pm"}'
#POST /leads/{id}/tasks
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
title | string | yes | "Call the customer" | |
due_at | string | no | ISO 8601, UTC | "2026-10-12T15:00:00Z" |
assignee_id | string (uuid) | no | A tenant staff member | "0c4a9d52-7e71-4c63-8f0b-2a9c6e1d3b44" |
The 201 Created response is the created task. Idempotency-Key is recommended. The task is linked to the lead. Today, tasks on the platform are linked to a contact and a conversation, not directly to a lead; the link to the lead is one of the code prerequisites (a release prerequisite).
curl -X POST https://formula-cream.pro/api/public/v1/leads/$LEAD_ID/tasks \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"title":"Call the customer","due_at":"2026-10-12T15:00:00Z"}'
#Notes
- Idempotency:
Idempotency-Keyis recommended for allPOSTandPATCHrequests; a retry with the same body returns the same result, and with a different body returns409 idempotency_conflict.PUTrequests (/leads/{id}/score,/crm/status-map) are idempotent by nature: the header is accepted and ignored. More: Conventions. - Limits: reads, writes, and paid operations are counted separately; the starting values are 600 / 120 / 30 requests per minute, and the current ones are visible in
GET /me(limits). - Events: lead changes arrive by webhooks (
lead.created,lead.updated,lead.status_changed,lead.scored) in the v2 envelope; the payload is thin, and content with PII comes through a subscription withinclude_pii: true; see Webhooks. - PII disclosures (responses with a contact) go into the disclosure log; the tenant sees it in the dashboard and through
GET /disclosures(PII transfer). - Release prerequisites: today the platform sends the
lead.createdevent not from every place where a lead is created, and there are no other lead events; a single lead write point, lead versions (version), the link of tasks to a lead (lead_id), and status validation will appear after code changes.
#Open questions
- The set and names of the public lead object's fields are not approved; the set shown follows the internal model (technical; to be settled in OpenAPI).
- The set of
GET /leadsfilters and the parameters ofaccept,notes, andtasksare proposed from the internal model and need approval (technical). - That errors for writing to another party's field map to the
invalid_statecode will be confirmed at release (technical).