Walker Formulation Academy for developers API reference Error catalog OpenAPI
Sections

#Leads API

Status: Available (reading) / Planned (writing). GET /leads and GET /leads/{id} work (scope leads:read). Creating, changing, accepting, scoring, notes and tasks are Planned: calls return 404. The lead fields below are the actual ones, as reading returns them (the Lead schema 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

CellURL
RUhttps://formula-cream.pro/api/public/v1
ENhttps://wfacademy.org/api/public/v1
IDhttps://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 pathScopeWhat it does
POST /leadsleads:writeCreate a lead (idempotent by external_id)
GET /leadsleads:readA list of leads with filters
GET /leads/{id}leads:readA lead by platform ID
GET /leads/by-external/{external_id}leads:readA lead by the ID in the external CRM
PATCH /leads/{id}leads:writeChange fields; requires If-Match and Idempotency-Key
POST /leads/{id}/acceptleads:writeAccept a lead into the external CRM
POST /leads/{id}/scoreleads:scoreScore a lead with our model (paid)
PUT /leads/{id}/scoreleads:writeRecord the external CRM's score (no charge)
POST /leads/{id}/actions/{action_id}/ackleads:writeConfirm or reject an agent's action request
POST /leads/{id}/notesleads:writeAdd a note
POST /leads/{id}/tasksleads:writeCreate 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

FieldTypeDescription
idstring (uuid)The lead's ID on the platform
external_idstring | nullThe lead's ID in the external CRM
external_urlstring | nullA link to the card in the external CRM
versioninteger | nullVersion for If-Match; null for now (the database has no version column) — use etag
etagstringVersion tag; equals the ETag header of the card (weak, like W/"…")
statusstringOne of six canonical statuses: new, qualified, proposal, negotiation, won, lost (see the status map). Archive is not a status but the archived flag
archivedbooleanThe lead is archived (a mark on the lead, not a status)
archived_reasonstring | nullThe archive reason, for example ai_not_client
owner"internal" | "external"Who owns the status, owner, and outcome (see modes)
titlestring | nullThe lead's title
sourcestring | nullThe source
assigned_tostring (uuid) | nullThe assignee: a tenant staff member only
valueobject | nullThe expected amount: {"amount": "15000.00", "currency": "RUB"}
product_idstring (uuid) | nullThe product the lead relates to
course_slugstring | nullThe course
contactobject | nullThe contact (see below)
conversation_idstring (uuid) | nullThe conversation the lead arose from
attributionobject | nullThe visitor's source and path (see below)
scoreobject | nullThe 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)
flagsobject | nullThe flags buy_intent, support, supplier (also in score.flags)
intentstring | nullThe intent identified by the agent
ai_summarystring | nullThe conversation summary
next_action, next_action_atstring | nullThe next action and its due time
lost_reasonstring | nullThe loss reason
qualified_at, won_at, lost_atstring (ISO 8601) | nullOutcome timestamps
last_activity_atstring (ISO 8601) | nullThe last activity
created_at, updated_atstring (ISO 8601)Created and changed, UTC

On the platform, a conversation never has two active leads: one conversation, one active lead.

#Contact (PII)

FieldTypeDescription
idstring (uuid)The contact's ID
first_name, last_namestring | nullFirst and last name
emailstring | nullEmail
phonestring | nullPhone
tagsarray of stringTags
geo_country, geo_citystring | nullGeography by the visitor's IP

#Attribution

FieldTypeDescription
utm_source, utm_medium, utm_campaignstring | nullCampaign tags
first_pagestring | nullThe first page of the visit
referrerstring | nullWhere 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.

ParameterTypeRequiredConstraintsExample
external_idstringyesUnique within the tenant and integration"crm-48213"
external_urlstringnoAn absolute https address"https://crm.example.com/leads/48213"
contactobjectyesAt least an email or a phone is required{"first_name":"Anna","email":"[email protected]"}
titlestringnoAny string"Course inquiry"
sourcestringnoAny string"landing"
valueobjectnoamount as a string, currency an ISO 4217 code{"amount":"15000.00","currency":"RUB"}
product_idstring (uuid)noA tenant product"8a6f3d10-2b94-4c7e-a1d5-0e9b7c4f2a11"
attributionobjectnoAttribution fields{"utm_source":"yandex"}
statusstringnoOne 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.

CodeHTTPWhen
validation_failed422Fields failed validation; details in errors[]
scope_missing403No leads:write
ip_not_allowed403A request from an address outside the key's IP list (in the RU cell the list is required)
idempotency_conflict409The same Idempotency-Key with a different body
rate_limited429The 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.

ParameterTypeRequiredConstraintsExample
limitintegerno1 to 100; 25 by default50
cursorstringnoThe next_cursor value from the previous response; valid for 24 hours"eyJ1Ijoi…"
include_deletedbooleannonot supported yet: true gives 422 not_supported (deleted leads are not stored)false
statusstringnoOne of six canonical statusesqualified
updated_sincestringnoleads changed not earlier than the moment (inclusive); ISO 8601, a date or a date-time with a zone2026-10-01T00:00:00Z
updated_afterstringnothe same, but strictly after the moment (a "since the last seen" traversal); not allowed together with updated_since2026-10-01T00:00:00Z
sourcestringnoexact value of the lead sourcewebsite
archivedbooleannotrue — archived only; by default (false) — non-archived onlyfalse

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 (scope leads: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).

ParameterTypeRequiredConstraintsExample
id (path)string (uuid)yesThe lead's ID on the platform5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b
external_id (path)stringyesThe ID in the external CRM; for by-external onlycrm-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.

ParameterTypeRequiredConstraintsExample
statusstringnoA canonical status"proposal"
assigned_tostring (uuid)noA staff member of your tenant only"0c4a9d52-7e71-4c63-8f0b-2a9c6e1d3b44"
valueobjectno{amount, currency}; amount is a string{"amount":"15000.00","currency":"RUB"}
notesstringnoAny string"Asked for a call back"
lost_reasonstringnoFor the lost status"too expensive"
external_id, external_urlstringnoThe ID and link in the external CRM"crm-48213"
SituationResponse
No If-Match428 precondition_required
The version is stale412 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:write403 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.

ParameterTypeRequiredConstraintsExample
external_idstringnoThe lead's ID in the external CRM"crm-48213"
external_urlstringnoAn 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

ParameterTypeRequiredConstraintsExample
textstringyesA 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

ParameterTypeRequiredConstraintsExample
titlestringyes"Call the customer"
due_atstringnoISO 8601, UTC"2026-10-12T15:00:00Z"
assignee_idstring (uuid)noA 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

#Open questions