Docs / Ingestion APIBrowse

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

POST https://perfbit.app/api/ingest

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 */ ]
}
OptionTypeDefaultDescription
api_key*stringYour app's API key. Must be non-empty.
app_version*stringThe 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_versionstringOptional. 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.

typeAdditional fields
screen_loadscreen_name string, load_duration_msnumber > 0
cold_startduration_msnumber > 0
warm_startduration_msnumber > 0
frame_metricswindow_duration_ms number > 0; total_frames, slow_frames, frozen_frames non-negative integers
session_startNone
session_endduration_ms number ≥ 0
session_crasherror_message string (optional), error_stack string (optional)
network_requesturl 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

StatusBodyCause
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.