#Events API
Status: Planned. The
POST /analytics/eventsmethod is being designed and is not yet available. This page records the agreed contract; values marked "provisional" will be finalized in OpenAPI 3.1. Until it ships, use the JS tracker and the modules.
Purpose: accept a batch of events from your systems: an SPA site, a mobile app, a server. This is the fourth path of analytics collection.
#Contents
- When to use it
- Access
- Request
- Event types
- Event fields
- Response
- Errors
- Deduplication and time
- Examples
- Recipes
- Relation to chat conversations and lead attribution
- Notes
- Open questions
#When to use it
| Situation | What to choose |
|---|---|
| A single-page application (SPA) | this method; the JS tracker does not support SPAs |
| A mobile app | this method, through your server |
| You need events: a request, a sign-up, a purchase | this method |
| Only page visits on an ordinary site | the JS tracker is simpler |
| Payments and deals in a CRM that are not tied to a browser | offline conversions |
#Access
| Method and path | POST https://<cell domain>/api/public/v1/analytics/events |
| Authentication | Authorization: Bearer <key> (Authentication) |
| Scope | analytics:write (Authentication); there are no paid scopes here |
| Paid | no |
| Limit | 120 requests per minute per key and tenant; at most 100 events per batch (Conventions) |
| Personal data | not accepted. The analytics:write scope allows only pseudonymous identifiers. Email and phone in properties are rejected with validation_failed |
| Idempotency | the Idempotency-Key header (24-hour window, Conventions) and the event_id field on each event |
| Key in the browser | not allowed. The key is a server secret; send events through your own server |
Cell addresses: RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id. A key works only in the cell where it was issued.
#Request
Headers
| Header | Required | Value |
|---|---|---|
Authorization | yes | Bearer <key> |
Content-Type | yes | application/json |
Idempotency-Key | yes | a unique string (usually a UUID v4); a repeat with the same key and body returns the earlier result |
Body
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
events | array | yes | from 1 to 100 events; body ≤ 256 KB | see below |
One batch may mix event types.
{
"events": [
{
"event_id": "evt_2026_10_11_0001",
"type": "page_view",
"session_id": "sess_EXAMPLE_01",
"anonymous_id": "anon_EXAMPLE_01",
"page_key": "/blog/emulsions",
"title": "Как сделать эмульсию",
"referrer": "https://www.google.com/",
"utm": { "source": "google", "medium": "cpc", "campaign": "emulsion-course", "content": "banner-a", "term": "эмульсия курс" },
"occurred_at": "2026-10-11T08:15:30Z",
"consent": true
}
]
}
#Event types
type | Meaning | Special fields |
|---|---|---|
page_view | a page or screen view | page_key, title, referrer |
event | a goal or action (a button click, a sign-up) | properties (the goal name goes in properties.name, provisional) |
lead_form | a request form submission | properties without contacts |
conversion | an action with an amount (a payment, a subscription) | value in the money format |
#Event fields
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
event_id | string | yes | unique within the integration; serves as the deduplication key | evt_2026_10_11_0001 |
type | string | yes | page_view | event | lead_form | conversion | page_view |
session_id | string | yes | a session identifier on the client side; the length will be finalized | sess_EXAMPLE_01 |
anonymous_id | string | yes | a visitor pseudonym; not an email or a phone number | anon_EXAMPLE_01 |
user_id | string | no | your internal pseudonymous id; not a contact | user_EXAMPLE_42 |
utm | object | no | source, medium, campaign, content, term: the five UTM tags | {"source":"google"} |
click_ids | object | no | ad-platform click identifiers, for example gclid, yclid, fbclid; the exact list will be finalized | {"yclid":"123"} |
page_key | string | for page_view | path without a language prefix, for example /blog/emulsions; the home page is /home | /blog/emulsions |
title | string | no | page title | Как сделать эмульсию |
referrer | string | no | source address | https://www.google.com/ |
occurred_at | string | no | ISO 8601 UTC; defaults to the receipt time; no earlier than 7 days ago (provisional) | 2026-10-11T08:15:30Z |
consent | boolean | yes | the visitor's consent to analytics at the time of the event | true |
properties | object | no | arbitrary key-to-scalar pairs; no personal data | {"plan":"pro"} |
value | object | for conversion | {"amount":"4990.00","currency":"RUB"}; the amount is a string | {"amount":"4990.00","currency":"RUB"} |
Events with consent: false are accepted but are not used for user attribution (exactly how will be finalized; see the open questions).
#Response
Success is 202 Accepted: the batch is accepted, and data aggregation runs after the response. This is not an asynchronous operation with an operation object: the receipt result for each event is already in the response body, in the same order as in the request.
{
"accepted": 2,
"rejected": 1,
"results": [
{ "event_id": "evt_2026_10_11_0001", "status": "accepted" },
{ "event_id": "evt_2026_10_11_0002", "status": "duplicate" },
{
"event_id": "evt_2026_10_11_0003",
"status": "rejected",
"error": { "code": "validation_failed", "field": "/events/2/properties/email", "message": "Контактные данные не принимаются" }
}
]
}
| Field | Type | Description |
|---|---|---|
accepted | integer | the number of accepted events (duplicates are not counted) |
rejected | integer | the number of rejected events |
results[].status | string | accepted, duplicate, rejected |
results[].error | object | for rejected events: code, field (JSON Pointer), message, the same shape as an errors[] element in the Conventions |
If the batch itself is invalid (not JSON, no events, more than 100 events), an error for the whole request is returned and no event is accepted.
#Errors
The format is application/problem+json; the shared code catalog is in the API conventions.
| HTTP | Code | When |
|---|---|---|
| 401 | invalid_api_key | the key is wrong, revoked, or expired |
| 403 | scope_missing | the key lacks analytics:write |
| 403 | ip_not_allowed | the request comes from an address outside the integration's IP list |
| 403 | feature_not_in_plan | the license does not include api_access |
| 409 | idempotency_conflict | the same Idempotency-Key with a different body |
| 413 | payload_too_large | the body is larger than 256 KB |
| 422 | validation_failed | the batch failed validation: not JSON, no events, more than 100 events (the list of fields is in errors) |
| 429 | rate_limited | the rate limit was exceeded |
| 503 | service_unavailable | the limits store is temporarily unavailable |
#Deduplication and time
- An event is identified by the pair "integration +
event_id". Resending the sameevent_idgivesstatus: "duplicate"and does not change the counters. This makes it safe to repeat a batch after a network error. occurred_atis accepted no earlier than 7 days ago (provisional) and no more than a few minutes in the future. Events older than the window are rejected withvalidation_failed. A separate mechanism will be used for historical loads when it appears.- If
occurred_atis not sent, the receipt time is used.
#Examples
#curl
curl -X POST 'https://formula-cream.pro/api/public/v1/analytics/events' \
-H 'Authorization: Bearer sap_ru_live_EXAMPLE0KEYI_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: batch-2026-10-11-0001' \
-d '{
"events": [
{
"event_id": "evt_2026_10_11_0001",
"type": "event",
"session_id": "sess_EXAMPLE_01",
"anonymous_id": "anon_EXAMPLE_01",
"page_key": "/pricing",
"properties": { "name": "signup_clicked" },
"occurred_at": "2026-10-11T08:15:30Z",
"consent": true
}
]
}'
#JavaScript (on your server)
// Node.js 18+. The key is kept in a server environment variable, not in the browser.
const response = await fetch('https://wfacademy.org/api/public/v1/analytics/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
events: [{
event_id: crypto.randomUUID(),
type: 'conversion',
session_id: 'sess_EXAMPLE_01',
anonymous_id: 'anon_EXAMPLE_01',
value: { amount: '49.00', currency: 'GBP' },
consent: true,
}],
}),
});
if (response.status !== 202) throw new Error(`API: ${response.status}`);
console.log(await response.json());
#Python
import os, uuid, requests
response = requests.post(
"https://wfacademy.id/api/public/v1/analytics/events",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"events": [{
"event_id": str(uuid.uuid4()),
"type": "page_view",
"session_id": "sess_EXAMPLE_01",
"anonymous_id": "anon_EXAMPLE_01",
"page_key": "/home",
"consent": True,
}]},
timeout=5,
)
response.raise_for_status()
#PHP
<?php
$payload = json_encode(['events' => [[
'event_id' => bin2hex(random_bytes(16)),
'type' => 'lead_form',
'session_id' => 'sess_EXAMPLE_01',
'anonymous_id' => 'anon_EXAMPLE_01',
'properties' => ['form' => 'callback'],
'consent' => true,
]]], JSON_UNESCAPED_UNICODE);
$ch = curl_init('https://formula-cream.pro/api/public/v1/analytics/events');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . bin2hex(random_bytes(16)),
],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
#Recipes
#Single-page application
- In the browser, create an
anonymous_id(a random string) on load and keep it on the client side; start a newsession_idafter every 30 minutes of inactivity. - On every route change, send a
page_viewevent to your server address (for example,/analytics), not directly to Walker Formulation Academy. - Your server adds
event_id, checksconsent, and forwards the batch to Walker Formulation Academy with the key. - Read UTM once on first load and pass it in all events of the session.
Forward events in batches of up to 100 every few seconds; this is cheaper than one at a time.
#Mobile app
- The app keeps
anonymous_idin secure storage and manages sessions according to platform rules. - Screens are
page_viewwith apage_keysuch as/screen/checkout. - An offline queue on the device is allowed for up to 7 days (the
occurred_atwindow);event_idmakes resending safe. - Events go to your backend, which forwards them to Walker Formulation Academy. Do not embed the key in the app.
#Relation to chat conversations and lead attribution
Walker Formulation Academy determines a lead's source from the session. When a visitor writes in the site chat, the session is linked to the conversation, and the attribution is saved in the lead card: source, UTM, entry page, the number of visits and page views, and the basis of the calculation (basis: funnel, form, or none).
The plan is to pass session_id from the Events API into the widget, so that events from your systems enter the visit history and the lead attribution (basis: funnel). For now the link is built on the JS tracker's session; for API events it is not yet defined (see the open questions).
#Notes
- The write limit is 120 requests per minute per key and tenant;
RateLimit-*headers and retries are covered in the API conventions. Event intake does not charge the wallet: no money is charged from the wallet. - Events neither contain nor return personal data; results are read only as aggregates (Reading analytics).
- The first versions accept only the listed event types.
#Open questions
- For the owner. The time window. "No older than 7 days" comes from the documentation brief; the project documents do not fix it.
- For the owner. The semantics of
consent: false: drop the event, or store it without identifiers. - Technical. The
event_idformat, thesession_idlength, theclick_idslist, and the goal name in theeventtype are not fixed. - Technical. Linking
session_idto the widget conversation. The design plans "sidin the iframe → attributionbasis: funnel", but the contract is not defined. - Technical. The response format (
accepted/rejected/results) is provisional. - Technical. Place in the stages: event intake is not assigned to a specific release stage anywhere.