#Lead scoring
Status: reading the score in the lead object (
GET /leads) is Available; triggering and recording a score through the API is Planned; scoring inside the platform is available. A lead is already scored inside the platform; you cannot trigger scoring or record your own through the API. Thegradeclass and persistence ofreasonare absent from the current implementation: they will appear together with the public API (see "Notes"). No dates are promised.
Scoring answers the question "how much does this lead look like a buyer". The model reads the recent conversation and assigns a number from 0 to 100; in parallel, a separate analysis sets flags (wants to buy, writes to support, is a supplier). The score helps a manager prioritize and lets automation filter out off-target inquiries.
Access: leads:read to read the score as part of a lead; leads:score to request scoring by our model; leads:write to record the external CRM's score. Paid: POST /leads/{id}/score: yes, charged to the tenant's wallet; PUT /leads/{id}/score: no. PII: not in the response itself; the score is built from a conversation with PII masking.
#What you get
| Field | Type | Description |
|---|---|---|
score | integer | A score from 0 to 100 |
grade | string | The lead's class, computed from score: A is 75 and above, B is 50-74, C is 25-49, D is below 25 |
reason | string | A short rationale, up to 500 characters. Stored together with the score |
flags | object | The flags buy_intent, support, supplier |
model | string | The model that produced the score |
scored_at | string (ISO 8601) | When it was produced |
source | string | The source of the score: ai is our model, external is the external CRM, manual is a manual score in the dashboard |
#Flags
Each flag takes one of three values: true, false, null.
| Flag | Meaning |
|---|---|
buy_intent | The person wants to buy |
support | This is a support inquiry, not a new customer |
supplier | This is a supplier or a mailing, not a customer |
null means "not sure". The analysis yields a probability, and a flag is set only when it is at least 0.8 (true) or at most 0.2 (false); between those values there is no flag. This band was chosen because it is where the analysis's accuracy was measured.
#How the score affects a lead
- If the model confidently determines
supplier = true, the score is capped at 10 and the lead goes to the archive with the reasonai_not_client. The lead is not deleted: archived leads can be viewed and restored. - The score does not change a lead's status automatically. The exception is the "Score" button in the dashboard, which moves the status forward only: up to 20 points
new, up to 50qualified, up to 70proposal, above thatnegotiation. WhetherPOST /leads/{id}/scorewill behave the same way is not defined in the design (see "Open questions"). - In the
externalmode, after a lead is accepted, our agents do not change the status at all; a change is proposed by an action request (see Agent actions).
#What the model sees
The model receives the last 10 messages of the conversation, without staff's internal notes. Before being sent to the model, personal data is removed from the text: phone numbers, emails, card numbers, messenger accounts, passport numbers, SNILS, INN, names, and addresses are replaced with placeholders. If a lead has no conversation or the conversation has no messages, scoring is not possible.
Model providers receive the processed text. More: PII transfer.
#When a lead is scored automatically
Automatic scoring is triggered in these cases:
- the automation rule "score the lead" fired;
- the lead came from the website or a bot and the AI replied in the conversation (today, only on the Walker Formulation Academy platform);
- a manager clicked "Score" in the dashboard;
- the CRM linking agent rechecked the lead after another reply.
For leads created through POST /leads without a conversation, automatic scoring is unavailable; such leads can be scored only with an external score (PUT).
#POST /leads/{id}/score
Triggers scoring by our model.
Headers: Authorization, Idempotency-Key (required: a paid operation). Parameters: no body is needed.
The operation runs synchronously, within seconds (the model has a 20-second timeout), and is not wrapped in an operation envelope (Conventions). A repeated request with the same Idempotency-Key does not charge money a second time.
200 OK response:
{
"lead_id": "5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b",
"score": 72,
"grade": "B",
"reason": "Клиент уточнил сроки старта и спросил о рассрочке.",
"flags": { "buy_intent": true, "support": false, "supplier": false },
"model": "model-EXAMPLE",
"scored_at": "2026-10-11T09:45:12Z",
"source": "ai",
"usage": {
"cost": { "amount": "0.12", "currency": "RUB" },
"model": "model-EXAMPLE"
}
}
The usage field follows the "money is visible" principle: the operation's cost is returned in the response as a string, in the currency of the tenant's wallet. Test keys do not charge money. The amount of the cost is not fixed in the design.
Possible failures. The internal reasons of today's scoring are mapped to codes in the catalog; the mapping is preliminary.
| Reason | Code | HTTP |
|---|---|---|
lead_not_found | not_found | 404 |
conversation_not_found, no_messages | invalid_state | 409 |
no_key | insufficient_funds | 402 |
model_failed, output_rejected, parse_failed | upstream_unavailable | 503 |
write_failed | service_unavailable | 503 |
Exceeding the spending limit, the limit of concurrent paid operations (5 per tenant), or the maximum cost of a single operation (the equivalent of 100 RUB) returns 402 spend_limit_reached. Retry 503 errors with the same Idempotency-Key.
#Examples
curl -X POST https://formula-cream.pro/api/public/v1/leads/$LEAD_ID/score \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 9b2d7a54-1f3c-4e8a-b6d0-5c7a2e9f1b30"
# API_KEY=sap_ru_test_EXAMPLE0KEYID_… (an example, not a real key)
const res = await fetch(
`https://formula-cream.pro/api/public/v1/leads/${leadId}/score`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
},
},
);
if (res.status === 402) throw new Error("Top up the wallet");
const result = await res.json();
r = httpx.post(
f"https://formula-cream.pro/api/public/v1/leads/{lead_id}/score",
headers={"Authorization": f"Bearer {os.environ['API_KEY']}",
"Idempotency-Key": str(uuid.uuid4())},
timeout=30,
)
result = r.json()
$ch = curl_init("https://formula-cream.pro/api/public/v1/leads/$leadId/score");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("API_KEY"),
"Idempotency-Key: " . bin2hex(random_bytes(16)),
],
]);
$result = json_decode(curl_exec($ch), true);
After a successful scoring, the lead.scored event is sent to subscribers.
#PUT /leads/{id}/score
Records your score (the external CRM). It is stored next to ours and does not replace it: in the field ownership table (see modes), the external score is called external_score. The scope is leads:write (not leads:score): writing does not charge the wallet. PUT is idempotent by nature; the Idempotency-Key header is accepted and ignored.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
score | integer | yes | 0-100 | 85 |
reason | string | no | Up to 500 characters | "Repeat customer" |
model | string | no | A label for the score's source | "crm-scoring-v3" |
Requires If-Match, like any lead change (428 precondition_required without the header, 412 precondition_failed for a stale version). The 200 OK response is a score object with source: "external" and a computed grade. A value outside 0-100 returns 422 validation_failed (errors[].field = /score).
curl -X PUT https://formula-cream.pro/api/public/v1/leads/$LEAD_ID/score \
-H "Authorization: Bearer $API_KEY" -H "If-Match: 3" \
-H "Content-Type: application/json" \
-d '{"score":85,"reason":"Повторный клиент"}'
#Events
| Event | When |
|---|---|
lead.scored | The lead was scored (by our model or by an external score) |
The payload is thin: the lead ID, the version, and the type. Fetch the score itself with GET /leads/{id}; a subscription with include_pii: true delivers the content in the event. Webhooks: the webhooks section.
#Notes
- Scoring settings (the model, enabling flags) are stored per tenant (
settings.crm.scoring); the cell's value serves as the default. You cannot choose an arbitrary model. - The
reasonrationale and the scoring time are stored together with the source (ai,external,manual); this is a code change made when the scoring service is extracted. Until the public API is released, today's scoring does not storereason. - The
gradeclass is not stored separately: it is computed fromscore(A >= 75, B 50-74, C 25-49, D < 25). - Scoring without an AI conversation is not supported.
- A repeated scoring overwrites the previous one.
#Open questions
- Will
POST /leads/{id}/scorechange the status (like the "Score" button in the dashboard) or only record the score (technical). - The cost of scoring: the actual range will be known after launch (technical).