Install
HTTP API
Send events from any language or engine with one POST request.
Every SDK sends events with the same HTTP request, and so can you. Use it from engines and languages without an SDK (Unreal, Flutter, Python, Go, Rust…), or to import events from your own systems.
curl -X POST https://api.anyanalytics.org/v1/batch \
-H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{"batch":[{"uuid":"'"$(uuidgen | tr A-Z a-z)"'","event":"$session_start","distinct_id":"user_123","session_id":"s_1","context":{"platform":"custom","app_version":"1.0.0"}}]}'Run it with your write key and the event appears in Live events within a second.
Endpoint and authentication
POST https://api.anyanalytics.org/v1/batch
Send your project's write key in one of these ways. The first one found is used:
| Where | Example |
|---|---|
| Authorization header | Authorization: Bearer vxw_YOUR_WRITE_KEY |
| X-Write-Key header | X-Write-Key: vxw_YOUR_WRITE_KEY |
| Query parameter | ?api_key=vxw_YOUR_WRITE_KEY |
| Body field | "api_key": "vxw_YOUR_WRITE_KEY" |
Write keys only send events; they can't read any data, so it's fine to ship them in apps and games. The body is JSON, whatever the Content-Type header says. Browsers can call the endpoint from any origin.
The request body
{
"sent_at": "2026-05-04T10:15:02.120Z",
"batch": [
{
"uuid": "0196a3c2-5b1e-7c4a-9f10-2b6d8e4a1c37",
"event": "level_completed",
"distinct_id": "player_8f2c",
"session_id": "s_20260504_1",
"timestamp": "2026-05-04T10:14:58.900Z",
"properties": { "level": 5, "score": 1200 },
"context": { "platform": "windows", "app_version": "1.4.0" }
}
]
}| Field | What it is |
|---|---|
batch | Required. 1 to 500 events. |
sent_at | Optional. Your device's clock when the request was sent. Used to correct event timestamps from devices whose clock is wrong. |
api_key | Optional. Your write key, if you don't send it in a header. |
Event fields
| Field | Rules |
|---|---|
event | Required. The event name, 1 to 200 characters. |
distinct_id | Required. Who did it: your user id, or an anonymous id you keep per device. 1 to 200 characters. |
uuid | Recommended. A UUID you create with the event. A uuid the project has already received is counted as a duplicate, not stored again. |
timestamp | When it happened: ISO 8601 with a time zone (Z or +02:00), or epoch milliseconds. Defaults to when it's received. |
session_id | Groups events into a session. Any string up to 64 characters. |
anonymous_id | The device's anonymous id, when distinct_id is already your user id. Links the two. Up to 200 characters. |
properties | Any JSON object, up to 32 KB. Defaults to {}. |
context | Where it happened: device and app fields (below), up to 8 KB. |
Context fields
These become filters and breakdowns in every report. Other keys are allowed and kept with the event.
| Field | Example |
|---|---|
platform | web, ios, android, windows, server… |
app_version | 1.4.0 |
app_build | 212 |
os | Windows, iOS, Android |
os_version | 17.5 |
device | iPhone15,2 |
device_type | desktop, mobile or tablet |
browser | Chrome |
browser_version | 126 |
locale | en-US |
timezone | Europe/Paris |
country | Two-letter code, such as US |
region | California |
city | San Francisco |
Leave out country, region and city and they're filled in from the request's IP address, except when platform is server or node: a server isn't where your user is. When platform is web or missing, browser and operating system are read from the request's User-Agent.
Timestamps
- With
sent_at, every timestamp in the batch is shifted by the difference between your device's clock and ours. Send a freshsent_aton each retry. - Events older than 60 days are rejected.
- Timestamps more than an hour in the future are replaced by the time received.
Track and identify
A custom event:
curl -X POST https://api.anyanalytics.org/v1/batch \
-H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{"batch":[{"uuid":"'"$(uuidgen | tr A-Z a-z)"'","event":"signup","distinct_id":"user_123","properties":{"plan":"pro","source":"pricing_page"}}]}'To tell us who a user is, send a $identify event from their user id. Put the anonymous id they had before in $anon_distinct_id, and the earlier events are linked to the same person. Traits in $set are saved on their profile.
curl -X POST https://api.anyanalytics.org/v1/batch \
-H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{"batch":[{"uuid":"'"$(uuidgen | tr A-Z a-z)"'","event":"$identify","distinct_id":"user_123","properties":{"$anon_distinct_id":"anon_456","$set":{"email":"[email protected]"}}}]}'$set works on any event, not only $identify. Use $set_once for traits that should keep their first value, such as a signup date. See Events and people.
Limits
| What | Limit |
|---|---|
| Events per request | 500 |
| Request body | 1 MiB (after decompression) |
| Event name, distinct_id | 200 characters |
| properties | 32 KB of JSON |
| context | 8 KB of JSON |
| session_id | 64 characters |
| Event age | 60 days |
Requests are also rate limited per project and per sender. When you go over, you get a 429 with a Retry-After header.
Compression
Large batches compress well. Gzip the body and add Content-Encoding: gzip:
gzip -c events.json | curl -X POST https://api.anyanalytics.org/v1/batch \
-H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
-H "Content-Type: application/json" \
-H "Content-Encoding: gzip" \
--data-binary @-The response
A 200 means the batch was processed. Each event is checked on its own, so one bad event never fails the others:
{
"accepted": 1,
"duplicates": 0,
"rejected": [],
"filtered": 0
}| Field | What it means |
|---|---|
accepted | Events stored. |
duplicates | Events whose uuid was already received. Not stored again. |
rejected | Invalid events, each with its position in the batch (index), its uuid when it had a valid one, and the errors. |
filtered | Valid events dropped on purpose: known bots, referrer spam, or traffic from IP addresses you excluded in the project's settings. Not an error. |
{
"accepted": 1,
"duplicates": 0,
"rejected": [
{ "index": 1, "uuid": "0196a3c2-5b20-7d11-8a4e-6c3f0b9d2e81", "errors": ["distinct_id: Too small: expected string to have >=1 characters"] }
],
"filtered": 0
}Tip
Rejected events also appear in Live events with the reason, which is the quickest way to debug a new integration.
Status codes
| Status | Meaning | What to do |
|---|---|---|
| 200 | Processed. Check rejected. | Done. |
| 400 | invalid_json or invalid_batch (with details) | Fix the request. Don't retry. |
| 401 | invalid_write_key | Check the key. Don't retry. |
| 413 | payload_too_large | Split the batch and send the halves. |
| 429 | rate_limited | Retry after Retry-After seconds. |
| 503 | Temporarily unavailable | Retry after Retry-After seconds. |
Retries
Network errors happen, especially on phones and consoles. To never lose or double count:
- Give every event a
uuidwhen you create it, and keep it. Without one, a retried event is stored twice. - Retry network errors,
408,429and5xxwith the same events and uuids. Wait forRetry-Afterwhen it's sent, otherwise back off exponentially (1 s, 2 s, 4 s… up to a minute or so). - On
413, split the batch. Never retry other4xxresponses: they'll fail the same way again. - Keep unsent events on disk, so they survive the app being closed.
The Unity, Godot, Swift and Kotlin files are short working examples of all of this, if you want to port one to another engine.
Examples
Python
# pip install requests
import time
import uuid
from datetime import datetime, timezone
import requests
HOST = "https://api.anyanalytics.org"
WRITE_KEY = "vxw_YOUR_WRITE_KEY"
def now():
return datetime.now(timezone.utc).isoformat()
def event(name, distinct_id, properties=None):
# The uuid is created once, with the event, and reused on every retry
return {
"uuid": str(uuid.uuid4()),
"event": name,
"distinct_id": distinct_id,
"timestamp": now(),
"properties": properties or {},
}
def send(events, attempts=6):
for attempt in range(attempts):
res = None
try:
res = requests.post(
HOST + "/v1/batch",
json={"sent_at": now(), "batch": events},
headers={"Authorization": "Bearer " + WRITE_KEY},
timeout=10,
)
except requests.RequestException:
pass # network error: retry below
if res is not None and res.ok:
for r in res.json()["rejected"]:
print("rejected", events[r["index"]]["event"], r["errors"])
return
if res is not None and res.status_code not in (408, 429) and res.status_code < 500:
# Bad key or bad payload: retrying won't help
raise RuntimeError(f"HTTP {res.status_code}: {res.text}")
retry_after = res.headers.get("Retry-After") if res is not None else None
time.sleep(float(retry_after) if retry_after else 2**attempt)
raise RuntimeError("gave up; keep the events and try again later")
send([event("order_completed", "user_123", {"order_id": "ord_123", "total": 49.9})])C# (.NET)
// .NET 6 or newer
using System;
using System.Net.Http;
using System.Net.Http.Json;
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
http.DefaultRequestHeaders.Add("Authorization", "Bearer vxw_YOUR_WRITE_KEY");
var batch = new[]
{
new
{
uuid = Guid.NewGuid().ToString(), // keep it if you resend this event
@event = "order_completed",
distinct_id = "user_123",
timestamp = DateTime.UtcNow.ToString("o"),
properties = new { order_id = "ord_123", total = 49.9 },
},
};
var response = await http.PostAsJsonAsync("https://api.anyanalytics.org/v1/batch", new
{
sent_at = DateTime.UtcNow.ToString("o"),
batch,
});
var status = (int)response.StatusCode;
if (status == 408 || status == 429 || status >= 500)
{
// Send the same batch again later, after Retry-After when it's given
var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
}
else if (!response.IsSuccessStatusCode)
{
// Bad key or bad payload: don't retry
Console.Error.WriteLine(await response.Content.ReadAsStringAsync());
}