#Offline Conversions
Status: Planned. The
POST /analytics/conversionsmethod is being designed. This page records the agreed principles; the set of fields is provisional and will be finalized in OpenAPI 3.1.
Purpose: pass a payment or a won deal from your CRM and accounting system to Walker Formulation Academy, so that the platform links the money to a lead, a session, and an acquisition channel and calculates channel payback.
#Contents
- Why it is needed
- When to use it
- Access
- Request
- Linking to a lead, a session, or an external identifier
- Response
- Errors
- Examples
- How conversions reach the reports
- Notes
- Open questions
#Why it is needed
Site counters see visits and requests, but not money: payment happens in the CRM, at the till, or on the bank account. Without it, the Walker Formulation Academy strategist knows how many leads a channel brought but not how much it earned. An offline conversion adds the missing "lead → payment" link and makes it possible to calculate channel payback.
In Overview, the funnel steps deals_created, deals_won, and revenue are built from the platform's CRM data. If your deals live in an external CRM, this method and Leads and CRM are the way to give the platform the same information. The rules by which conversions and platform deals are merged without double counting are in How conversions reach the reports.
#When to use it
| Situation | What to choose |
|---|---|
| A payment or a won deal is recorded in your CRM or accounting | this method |
| The event happened in a browser or an app (a click, a sign-up) | Events API |
| You need to update a lead's status | CRM statuses |
| You need amounts by channel | Reading analytics |
#Access
| Method and path | POST https://<cell domain>/api/public/v1/analytics/conversions |
| Authentication | Authorization: Bearer <key> (Authentication) |
| Scope | analytics:write |
| Paid | no |
| Limit | 120 requests per minute per key and tenant (Conventions) |
| Personal data | not accepted. The analytics:write scope allows only pseudonymous identifiers: linking is by lead identifier, session, or your external identifier, not by email or phone. Contacts in properties are rejected with validation_failed |
| Idempotency | the Idempotency-Key header (Conventions) and the conversion_id field |
Cell addresses: RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id. The amount's currency matches the cell's market: RUB for RU, GBP for EN, IDR for ID (Cells and data).
#Request
Headers: Authorization, Content-Type: application/json, Idempotency-Key (Conventions).
Body (provisional)
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
conversion_id | string | yes | your unique conversion identifier; a repeat does not create a second one | pay_48213_1 |
type | string | yes | payment or deal_won (a won deal); the list will be finalized | payment |
value | object | yes | {"amount":"4990.00","currency":"RUB"}; the amount is a string, the currency is the cell's. A negative amount is a refund (see below) | {"amount":"4990.00","currency":"RUB"} |
occurred_at | string | yes | ISO 8601 UTC, the moment of payment | 2026-10-11T08:15:30Z |
lead_id | string | one of three | the lead identifier in Walker Formulation Academy | lead_EXAMPLE_01 |
external_id | string | one of three | the identifier of the lead or deal in your CRM (the external_id field, see Leads) | crm-48213 |
session_id | string | one of three | a session passed in the Events API | sess_EXAMPLE_01 |
properties | object | no | additional key-to-scalar pairs, no personal data | {"product":"emulsion-mastery"} |
At least one of lead_id, external_id, session_id is required. If several are given, priority follows the order lead_id, external_id, session_id (provisional).
{
"conversion_id": "pay_48213_1",
"type": "payment",
"value": { "amount": "4990.00", "currency": "RUB" },
"occurred_at": "2026-10-11T08:15:30Z",
"external_id": "crm-48213",
"properties": { "product": "emulsion-mastery" }
}
#Linking to a lead, a session, or an external identifier
| Method | When it fits | What the platform gets |
|---|---|---|
lead_id | the lead was created or accepted through the leads API | a direct link to the lead and its attribution (source, UTM, visit history) |
external_id | the lead was created by your CRM with POST /leads using the same external_id | a link through your CRM's identifier |
session_id | there is no request; the conversion comes from a visitor (for example, a purchase without a form) | a link to the session and its channel |
If the lead or session is not found, the conversion is rejected with the code not_found; the platform does not create "dangling" conversions.
#Response
Success is 201 Created for a new conversion and 200 OK on a repeat with the same conversion_id. The method is not asynchronous: no operation object is returned, and the reports are recalculated after the response.
{
"conversion_id": "pay_48213_1",
"status": "attributed",
"lead_id": "lead_EXAMPLE_01",
"attribution": { "channel": "search", "basis": "form" },
"created_at": "2026-10-11T08:16:02Z"
}
| Field | Type | Description |
|---|---|---|
conversion_id | string | your identifier |
status | string | attributed: linked to a channel; unattributed: accepted, but the channel is unknown (the lead has no attribution) |
lead_id | string | null | the lead that was found |
attribution.channel | string | null | the channel the conversion was assigned to |
attribution.basis | string | the basis: funnel, form, or none |
The response field names are provisional.
#Errors
The format is application/problem+json; the codes are in the error catalog.
| HTTP | Code | When |
|---|---|---|
| 401 | invalid_api_key | the key is wrong, revoked, or expired |
| 403 | scope_missing | the analytics:write scope is missing |
| 404 | not_found | the lead or session was not found |
| 403 | ip_not_allowed | the request comes from an address outside the integration's IP list |
| 409 | idempotency_conflict | the same Idempotency-Key with a different body |
| 422 | validation_failed | there is no link at all; the amount is not a string; the currency does not match the cell's currency; contact data in properties |
| 429 | rate_limited | the rate limit was exceeded |
| 503 | service_unavailable | the limits store is temporarily unavailable |
#Examples
#curl
curl -X POST 'https://formula-cream.pro/api/public/v1/analytics/conversions' \
-H 'Authorization: Bearer sap_ru_live_EXAMPLE0KEYI_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: pay_48213_1' \
-d '{
"conversion_id": "pay_48213_1",
"type": "payment",
"value": { "amount": "4990.00", "currency": "RUB" },
"occurred_at": "2026-10-11T08:15:30Z",
"external_id": "crm-48213"
}'
#Python
import os, requests
response = requests.post(
"https://wfacademy.org/api/public/v1/analytics/conversions",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
"Idempotency-Key": "pay_48213_1",
},
json={
"conversion_id": "pay_48213_1",
"type": "payment",
"value": {"amount": "49.00", "currency": "GBP"},
"occurred_at": "2026-10-11T08:15:30Z",
"external_id": "crm-48213",
},
timeout=5,
)
response.raise_for_status()
#How conversions reach the reports
The deals_won and revenue metrics in analytics (Reading analytics) come from the platform's CRM or from offline conversions, without double counting:
- a conversion with a
lead_idwhose platform CRM record already has a won deal replaces that deal's amount; if a lead has several conversions, the latest byoccurred_atapplies; - a conversion for a lead with no won deal is added as a new won deal for the
valueamount; - in the
externalandbothCRM modes (modes) the same principle applies: the platform does not add a deal and a conversion for the same lead together.
A conversion also appears in the by-channel breakdown. The strategist uses it to calculate channel payback and to prioritize topics and advertising. Data does not appear in the reports instantly: the recalculation runs after receipt.
#Refunds
A refund is passed as a conversion with a negative amount in value.amount (for example, "-4990.00") and its own conversion_id. It reduces revenue for the period that contains the refund's occurred_at.
{
"conversion_id": "refund_48213_1",
"type": "payment",
"value": { "amount": "-4990.00", "currency": "RUB" },
"occurred_at": "2026-10-20T10:00:00Z",
"external_id": "crm-48213"
}
#Notes
- Money is passed only as a string and only in the cell's currency; there is no currency conversion.
- A repeat with the same
conversion_idis safe. - Refunds are passed as a negative amount (Refunds).
#Open questions
- Technical. The set and names of the request and response fields are provisional: the design specifies only the idea of "a payment or a deal tied to a lead/session".
- For the owner. Multi-currency: whether it is acceptable to send an amount in a currency other than the cell's.
- Technical. The maximum age window for
occurred_atand the order of recalculating payback for late conversions; partial payments and amount adjustments (other than refunds). - For the owner. The
analytics:writescope is shared with events; there is no separate permission for conversions (money data).