Ingestion API
The SDK is a client for one endpoint. If you need to send events from somewhere the SDK doesn't run — a test harness, a different platform, your own wrapper — this is the contract.
Endpoint
Content-Type: application/json. One request carries a batch of events that share the same app version, environment and platform.
Authentication
Heads up
The API key goes in the JSON body as api_key, not in a header. There is no Authorization header and no bearer token. A request with no recognised key is rejected with 401.
Keys are per-app. Because the key ships inside your mobile binary it should be treated as a write-only credential — it grants nothing but the ability to submit events for that app, and cannot read any data back.
Request body
{
"api_key": "string",
"app_version": "string",
"environment": "development" | "staging" | "production",
"platform": "ios" | "android" | "web",
"sdk_version": "string",
"events": [ /* 1-200 events */ ]
}| Option | Type | Default | Description |
|---|---|---|---|
| api_key* | string | — | Your app's API key. Must be non-empty. |
| app_version* | string | — | The build these events came from. Becomes the build dimension in the dashboard. |
| environment* | "development" | "staging" | "production" | — | Only production events are surfaced in the dashboard. |
| platform* | "ios" | "android" | "web" | — | Rejected if it is any other value. |
| sdk_version | string | — | Optional. Recorded alongside each event. |
| events* | Event[] | — | Between 1 and 200 events. An empty array is a 400. |
Event shape
Every event carries these three fields:
{
"type": "screen_load", // one of the eight types below
"timestamp": 1718123456789, // epoch milliseconds
"session_id": "550e8400-e29b-41d4-a716-446655440000" // must be a UUID
}Note
session_id must be a valid UUID. This is the most common cause of a 400 when hand-rolling requests — an arbitrary string like "test-session" is rejected.
Event types
The type field selects which additional fields are required. Validation is a discriminated union, so an unknown type fails the whole request.
| type | Additional fields |
|---|---|
screen_load | screen_name string, load_duration_msnumber > 0 |
cold_start | duration_msnumber > 0 |
warm_start | duration_msnumber > 0 |
frame_metrics | window_duration_ms number > 0; total_frames, slow_frames, frozen_frames non-negative integers |
session_start | None |
session_end | duration_ms number ≥ 0 |
session_crash | error_message string (optional), error_stack string (optional) |
network_request | url valid URL, method string, duration_ms number ≥ 0; status_code integer, request_size_bytes, response_size_bytes optional |
Responses
200 OK
{ "received": true, "count": 1 }Errors
| Status | Body | Cause |
|---|---|---|
400 | { "error": "invalid_payload", "details": [...] } | Schema validation failed. details lists the offending fields. |
400 | { "error": "invalid_payload", "details": "malformed JSON" } | The body could not be parsed as JSON. |
401 | { "error": "invalid_api_key" } | No app matches the supplied api_key. |
429 | { "error": "rate_limited", "retry_after": 42 } | Rate limit exceeded. retry_after is in seconds. |
500 | { "error": "internal_error" } | Something failed server-side. Retry with backoff. |
Validation runs before the key lookup, so a malformed payload returns 400 even when the key is also wrong.
Rate limits
- 100 requests per 60-second window, per API key. The window is fixed, not sliding — it resets 60 seconds after the first request in the window.
- 200 events per request, giving a ceiling of 20,000 events per minute per key.
On a 429, wait retry_after seconds before retrying. The SDK batches at 50 events and flushes every 30 seconds, which keeps normal usage far below the request limit.
Monthly event allowances are a separate, plan-level concern — see API keys & plans.
Example
curl -X POST https://perfbit.app/api/ingest \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_API_KEY",
"app_version": "1.0.0",
"environment": "production",
"platform": "ios",
"sdk_version": "0.3.0",
"events": [{
"type": "screen_load",
"timestamp": 1718123456789,
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"screen_name": "HomeScreen",
"load_duration_ms": 342
}]
}'This is also the fastest way to prove a key works end to end. Swap the host for http://localhost:3000 when running perfbit locally.
Ingestion continues after your trial ends
If your subscription lapses, the endpoint keeps accepting events — the dashboard is what gets locked, not collection. Your data continues to accumulate and becomes visible again the moment you subscribe, so a gap in billing does not become a permanent gap in your history.