curl
Every request from the terminal — an event, a session, a batch, user_id, late offline delivery, gzip, retries, parsing responses with jq, the Read API, and data deletion.
curl is handy for checking a key, eyeballing the server's response, and sending events from shell scripts and cron jobs. A client for backends and CLIs lives in the Go recipe; server-side sessions and Telegram bots are covered in the Bots and backends recipe.
The examples assume bash with curl, uuidgen, and date. Parsing responses requires jq.
Environment variables
The pk_… write key is shown in the dashboard: open the app and go to the Integration tab. Each
platform of an app has its own key. The key isn't secret — it ships inside the app (see
Quick start). All examples read it from the AM_PK variable and pass
context.platform explicitly, so they work with any platform's key (for how the platform is
determined, see Event platform):
export AM_PK="pk_…"Events are accepted by ingest at /v1/events. The examples use the placeholder domain
https://i.analytics.example.com: replace it with your installation's ingest URL, which is already
filled in to the snippets on the Integration tab.
The Read API needs a different, secret key — see Read API.
Techniques shared by all the examples:
- time —
date -u +"%Y-%m-%dT%H:%M:%S.000Z": RFC 3339 in UTC with milliseconds; - identifiers —
uuidgen | tr A-Z a-z: on macOS, uuidgen prints UUIDs in upper case, while lower case is the recommended format; "environment": "development"— test events stay out of production reports (see Debugging);"ip": "none"— don't derive the country and city from the address of the machine running curl (see context.ip).
A single event
The smallest possible batch is a single session_start:
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
curl -sS https://i.analytics.example.com/v1/events \
-H "Content-Type: application/json" \
-H "X-App-Key: $AM_PK" \
--data-binary @- <<JSON
{
"sent_at": "$NOW",
"context": { "platform": "server", "app_version": "1.0.0", "environment": "development", "ip": "none" },
"events": [
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_start",
"timestamp": "$NOW",
"install_id": "$(uuidgen | tr A-Z a-z)",
"session_id": "$(uuidgen | tr A-Z a-z)"
}
]
}
JSONThe 200 response arrives only after the events are written to storage:
{ "accepted": 1, "rejected": [], "warnings": [], "server_time": "2026-10-14T09:15:02.318Z" }A complete session
session_start and session_end of the same session share session_id and install_id.
session_end requires a duration_ms field — foreground time in milliseconds, from 0 to
86,400,000. Without it, the event is rejected with the duration_required code.
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
INSTALL_ID=$(uuidgen | tr A-Z a-z)
SESSION_ID=$(uuidgen | tr A-Z a-z)
curl -sS https://i.analytics.example.com/v1/events \
-H "Content-Type: application/json" \
-H "X-App-Key: $AM_PK" \
--data-binary @- <<JSON
{
"sent_at": "$NOW",
"context": {
"platform": "server",
"os_name": "Linux",
"app_version": "1.0.0",
"device_type": "server",
"environment": "development",
"ip": "none"
},
"events": [
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_start",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID"
},
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_end",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID",
"duration_ms": 60000
}
]
}
JSONIn an app, session_end is sent the moment the app goes to the background, and duration_ms is
measured with a monotonic clock (see Sessions).
A batch of several events
A single request carries 1 to 500 events and no more than 1 MiB after decompression — this is how an
offline queue sends what it has accumulated. The batch context applies to all events, and an
event's own context overrides it field by field. The example has two sessions from the same
install, and the second one happened after the app was updated to 1.1.0:
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
INSTALL_ID=$(uuidgen | tr A-Z a-z)
FIRST=$(uuidgen | tr A-Z a-z)
SECOND=$(uuidgen | tr A-Z a-z)
curl -sS https://i.analytics.example.com/v1/events \
-H "Content-Type: application/json" \
-H "X-App-Key: $AM_PK" \
--data-binary @- <<JSON
{
"sent_at": "$NOW",
"context": { "platform": "server", "app_version": "1.0.0", "environment": "development", "ip": "none" },
"events": [
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_start",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$FIRST"
},
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_end",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$FIRST",
"duration_ms": 95000
},
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_start",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$SECOND",
"context": { "app_version": "1.1.0" }
},
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_end",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$SECOND",
"duration_ms": 42000,
"context": { "app_version": "1.1.0" }
}
]
}
JSONEach event has its own insert_id, which the server uses to drop duplicates. If an insert_id
repeats within a batch, the first event wins and the response includes the
duplicate_insert_id_in_batch warning. An invalid event doesn't fail the batch: the rest are
accepted, and the invalid one ends up in rejected.
user_id after sign-in
Before sign-in, events go out without user_id. After sign-in, add the account ID from your backend
to every event; after sign-out, send events without it again. A session that started anonymously can
end with a user_id:
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
INSTALL_ID=$(uuidgen | tr A-Z a-z)
SESSION_ID=$(uuidgen | tr A-Z a-z)
USER_ID="acct_8f14e45f" # account ID from your backend, not an email or phone number
curl -sS https://i.analytics.example.com/v1/events \
-H "Content-Type: application/json" \
-H "X-App-Key: $AM_PK" \
--data-binary @- <<JSON
{
"sent_at": "$NOW",
"context": { "platform": "server", "environment": "development", "ip": "none" },
"events": [
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_start",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID"
},
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_end",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID",
"user_id": "$USER_ID",
"duration_ms": 120000
}
]
}
JSONThe server retroactively attributes the install's anonymous events to this user: reports show one
person, u:acct_8f14e45f, not two (see Identity). A user_id
that looks like an email or phone number is rejected with the user_id_looks_like_pii code.
Late delivery from an offline queue
A batch carries two times, both taken from the device clock:
- the event's
timestamp— when the event happened; - the batch's
sent_at— when this HTTP request was sent.
The server computes the event time as received_at + (timestamp − sent_at), where received_at is
the time of receipt by the server's own clock. That's why an event from the offline queue lands on
the day it actually happened, even if the device clock runs fast or slow. Here's a session from two
days ago, sent now:
FMT="+%Y-%m-%dT%H:%M:%S.000Z"
# Two days ago: the first command is for GNU date (Linux), the second for BSD date (macOS).
STARTED=$(date -u -d "-2 days" "$FMT" 2>/dev/null || date -u -v-2d "$FMT")
ENDED=$(date -u -d "-2 days +5 minutes" "$FMT" 2>/dev/null || date -u -v-2d -v+5M "$FMT")
NOW=$(date -u "$FMT")
INSTALL_ID=$(uuidgen | tr A-Z a-z)
SESSION_ID=$(uuidgen | tr A-Z a-z)
curl -sS https://i.analytics.example.com/v1/events \
-H "Content-Type: application/json" \
-H "X-App-Key: $AM_PK" \
--data-binary @- <<JSON
{
"sent_at": "$NOW",
"context": { "platform": "server", "environment": "development", "ip": "none" },
"events": [
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_start",
"timestamp": "$STARTED",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID"
},
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_end",
"timestamp": "$ENDED",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID",
"duration_ms": 300000
}
]
}
JSONWhat matters for the queue:
sent_atis the moment of this particular attempt: set it anew on every retry, and leave the events'timestampalone;- the late-delivery window is 7 days, inclusive. Older events are rejected with the
event_too_oldcode andretryable: false, so remove them from the queue before sending; - a time more than 10 minutes in the future is replaced with the time of receipt, and the response
includes the
timestamp_in_future_clampedwarning.
Learn more in Time and the offline queue.
Debugging: the development environment
context.environment is production (the default) or development. Reports show production, and
development is turned on with the Environment switch, so test requests don't skew your numbers.
All examples on this page send development:
"context": { "platform": "server", "environment": "development", "ip": "none" }Sent events and rejection reasons are visible on the Integration tab. For real events, drop
environment or pass "production". Like the other context fields, environment can be set in the
context of an individual event.
gzip
Large batches are worth compressing. The only compression algorithm the server understands is
gzip: any other Content-Encoding value returns 400 unsupported_content_encoding. The 1 MiB limit
applies after decompression.
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
cat > batch.json <<JSON
{
"sent_at": "$NOW",
"context": { "platform": "server", "environment": "development", "ip": "none" },
"events": [
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_start",
"timestamp": "$NOW",
"install_id": "$(uuidgen | tr A-Z a-z)",
"session_id": "$(uuidgen | tr A-Z a-z)"
}
]
}
JSON
gzip -c batch.json | curl -sS https://i.analytics.example.com/v1/events \
-H "Content-Type: application/json" \
-H "Content-Encoding: gzip" \
-H "X-App-Key: $AM_PK" \
--data-binary @-Retries on 429 and 503
The whole batch is retried in two cases:
429(more than 2,000 events per second per app) and503(storage didn't confirm the write) — after as many seconds as the server specified in theRetry-Afterheader;500and network errors — with a delay of 1, 2, 4 … 300 s and ±20% jitter.
The script generates insert_id once, before the loop: if the first request got through but the
response was lost, the retry won't create a duplicate. sent_at is set anew on every attempt.
INSERT_ID=$(uuidgen | tr A-Z a-z) # one for all attempts
INSTALL_ID=$(uuidgen | tr A-Z a-z)
SESSION_ID=$(uuidgen | tr A-Z a-z)
EVENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
backoff=1
for attempt in 1 2 3 4 5 6 7 8; do
cat > batch.json <<JSON
{
"sent_at": "$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")",
"context": { "platform": "server", "environment": "development", "ip": "none" },
"events": [
{
"insert_id": "$INSERT_ID",
"name": "session_start",
"timestamp": "$EVENT_TIME",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID"
}
]
}
JSON
status=$(curl -sS -o response.json -D headers.txt -w "%{http_code}" \
https://i.analytics.example.com/v1/events \
-H "Content-Type: application/json" \
-H "X-App-Key: $AM_PK" \
--data-binary @batch.json) || status=000
case "$status" in
200) break ;;
429 | 503) # the pause comes from the Retry-After header
pause=$(tr -d '\r' < headers.txt | awk -F': *' 'tolower($1) == "retry-after" { print $2 }')
pause=${pause:-5} ;;
4??) break ;; # 400, 401, 403, 413, 415: retrying won't help
*) # 5xx and network errors (000): 1, 2, 4 … 300 s, ±20% jitter
pause=$(( backoff * (80 + RANDOM % 41) / 100 ))
pause=$(( pause < 1 ? 1 : pause ))
backoff=$(( backoff * 2 > 300 ? 300 : backoff * 2 )) ;;
esac
echo "attempt $attempt: HTTP $status, retrying in $pause s" >&2
sleep "$pause"
done
if [ "$status" = 200 ]; then
cat response.json
else
echo "batch not sent: HTTP $status" >&2
[ -f response.json ] && cat response.json >&2
false # non-zero exit code for cron and CI
fiWhy the other 4xx responses aren't retried:
400,413,415— the batch is broken, and the same request will get the same response. Fix the client:error.codeanderror.detailspoint to the offending field;401,403— the key was revoked or the app is archived. Retrying won't help until someone issues a new key or restores the app from the archive. Don't delete the events in the meantime;429is the only temporary4xx: the limit frees up afterRetry-Afterseconds.
Events that come back in rejected with retryable: true (currently only install_rate_limited)
should go out in the next batch no sooner than 60 s later. The full table of actions is in
Errors and retries.
Parsing the response with jq
On 200, the server returns an IngestResult: accepted is the number of accepted events,
rejected lists rejected events with a reason code, warnings lists events accepted with a
correction, and server_time is the server's time. In the example, the second event is a
session_end without duration_ms:
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
INSTALL_ID=$(uuidgen | tr A-Z a-z)
SESSION_ID=$(uuidgen | tr A-Z a-z)
curl -sS https://i.analytics.example.com/v1/events \
-H "Content-Type: application/json" \
-H "X-App-Key: $AM_PK" \
-o response.json \
--data-binary @- <<JSON
{
"sent_at": "$NOW",
"context": { "platform": "server", "environment": "development", "ip": "none" },
"events": [
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_start",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID"
},
{
"insert_id": "$(uuidgen | tr A-Z a-z)",
"name": "session_end",
"timestamp": "$NOW",
"install_id": "$INSTALL_ID",
"session_id": "$SESSION_ID"
}
]
}
JSON
jq -r '"accepted: \(.accepted)"' response.json
jq -r '.rejected[] | "rejected events[\(.index)]: \(.code) at \(.field), retryable: \(.retryable)"' response.json
jq -r '.warnings[] | "warning events[\(.index)]: \(.code)"' response.jsonaccepted: 1
rejected events[1]: duration_required at events[1].duration_ms, retryable: falseIn a script, it's convenient to exit with an error if the server rejected at least one event:
jq -e returns a non-zero exit code when the expression is false.
jq -e '.rejected == []' response.json > /dev/nullA batch error (4xx, 5xx) comes in a different format — an error envelope. Its request_id
matches the X-Request-Id header and lets you find the request in the server logs:
jq -r '.error | "\(.code): \(.message) (request_id \(.request_id))"' response.jsonRead API
Reports are read with the same curl, just with a different URL and key. The secret sk_… key is
created under Settings → API keys and shown only once; reports require the read scope. Unlike
pk_…, this key never ships inside an app: it unlocks your reports.
export AM_SK="sk_…"List apps
curl -sS https://api.analytics.example.com/v1/apps \
-H "Authorization: Bearer $AM_SK" \
| jq -r '.items[] | [.id, .platform, .timezone, .name] | @tsv'0199d5f0-0000-7000-8000-000000000002 ios Europe/Moscow Notes
0199d5f0-0000-7000-8000-000000000003 android Europe/Moscow NotesThe include_archived=true parameter adds archived apps.
Report: daily active users
The id from the list is the app_id in the report URL. Daily active users over a week:
APP_ID="0199d5f0-0000-7000-8000-000000000002"
curl -sS -G "https://api.analytics.example.com/v1/apps/$APP_ID/audience/timeseries" \
-H "Authorization: Bearer $AM_SK" \
--data-urlencode "from=2026-10-06" \
--data-urlencode "to=2026-10-12" \
--data-urlencode "metric=active" \
--data-urlencode "granularity=day" \
--data-urlencode "compare=none" \
| jq -r '.points[] | "\(.ts[0:10])\t\(.value)"'2026-10-06 40987
2026-10-07 41230
2026-10-08 40876
2026-10-09 41502
2026-10-10 39811
2026-10-11 38420
2026-10-12 40115ts is the start of the bucket with the app's time zone offset, so its first 10 characters are the
report date. metric is active, new, sessions, or avg_duration_ms; granularity is hour
(for periods of up to 7 days), day, week, or month. Shared parameters and the other endpoints
are described in Read API: overview.
The Read API is rate-limited: beyond 10 requests per second per key, requests get 429 rate_limited
with Retry-After. Retry them with the same logic as ingest batches.
Deleting user data
A deletion request (for example, under GDPR) goes through the Read API with a key that has the
delete scope. subject_type is user (by user_id: the events of the user and of every install
linked to them) or install (by install_id). An entire app can only be deleted in the dashboard.
APP_ID="0199d5f0-0000-7000-8000-000000000002"
REQUEST_ID=$(curl -sS -X POST "https://api.analytics.example.com/v1/apps/$APP_ID/deletion-requests" \
-H "Authorization: Bearer $AM_SK" \
-H "Content-Type: application/json" \
-d '{"subject_type": "user", "subject_value": "acct_8f14e45f"}' \
| jq -r .id)
while :; do
STATUS=$(curl -sS "https://api.analytics.example.com/v1/apps/$APP_ID/deletion-requests/$REQUEST_ID" \
-H "Authorization: Bearer $AM_SK" | jq -r .status)
echo "$STATUS"
case "$STATUS" in
pending | running) sleep 10 ;;
*) break ;; # done, failed, or a request error
esac
doneCreating a request returns 202 and the request itself with the pending status. Repeating the call
with the same parameters before the deletion finishes returns the same request:
{
"id": "0199d7a2-4c1e-7b3d-9a5f-2e8c6d0b1f34",
"app_id": "0199d5f0-0000-7000-8000-000000000002",
"subject_type": "user",
"subject_value": "acct_8f14e45f",
"status": "pending",
"source": "api",
"requested_at": "2026-10-14T09:20:11.042Z",
"started_at": null,
"completed_at": null,
"error": null
}The status can be read with a key that has the read or delete scope. done means the data has
been removed from every table; failed means the deletion didn't succeed after several attempts, and
the reason is in the error field. Learn more in Privacy and GDPR.