Documentation
REST API
Browser ingest and platform endpoint reference
Environments
Every example uses shell variables instead of a fixed host, so nothing falls back to production. Set them for one environment at a time. A public key is registered in one environment only; another environment’s Worker rejects it with 401.
| Environment | Ingest Worker | $REALANALYTICS_INGEST_URL | $REALANALYTICS_DASHBOARD_URL |
|---|---|---|---|
| Local | realanalytics-ingest-local | The loopback URL printed by wrangler dev in apps/ingest. The default profile only reaches loopback services, delivers directly without a queue, has no rate limiter and is unmetered. | http://localhost:3000 from bun run dev:app |
| Staging | realanalytics-ingest-staging | The endpoint on the project’s Install page in the staging dashboard, or from the staging operator. | https://app.staging.realanalytics.dev |
| Production | realanalytics-ingest | The endpoint on the project’s Install page in the production dashboard. | https://app.realanalytics.dev |
$REALANALYTICS_PUBLIC_KEY is the project’s browser key from the same Install page. The dashboard withholds its install snippets until its operator sets that environment’s ingest endpoint. See Installation.
Send browser events
POST $REALANALYTICS_INGEST_URL
The browser SDK uses this endpoint. Call it directly only when you must reproduce the SDK’s request, for example to check a key or a schema from a terminal. It accepts cross-origin requests from any site, with Content-Type and X-API-Key headers.
curl -X POST "$REALANALYTICS_INGEST_URL" \
-H "Content-Type: application/json" \
-H "X-API-Key: $REALANALYTICS_PUBLIC_KEY" \
-d '[
{
"eventId": "3f0c2a4e-8b1d-4c6f-9e2a-5d7b1c0e4f21",
"event": "signup_completed",
"timestamp": "2026-09-30T10:00:00.000Z",
"distinctId": "anon_7d2f4c1a",
"sessionId": "sess_4b9e2d71",
"source": "browser",
"properties": {
"plan": "pro",
"source": "pricing_page",
"$consent_analytics": "granted",
"$consent_ad_user_data": "denied",
"$consent_ad_personalization": "denied",
"$consent_recorded_at": "2026-09-30T09:59:58.000Z",
"$consent_source": "cmp"
}
}
]'signup_completed is a custom event, so the project must have deployed a schema that declares it. This example matches the definition in the Quick Start. Built-in events need no schema entry: $pageview, $pageleave, $click, $scroll_depth, $rage_click, $form_start, $form_submit, $form_abandon, $outbound_click, $file_download, $error, $web_vital, $identify, $reset, $set_user.
Authentication
Send a registered pk_live_ or pk_test_ browser key of 9–256 characters in the X-API-Key header. When the header is absent, the Worker reads apiKey from a { apiKey, events } body. Never send an sk_ deploy key or a cvk_ conversion key here.
Request bodies
- An array of events, from 1 to 100 per request
- A single event object
- An envelope
{ "apiKey": "…", "events": [...] }, which the SDK sends withnavigator.sendBeaconwhen the page is hidden or unloaded
{
"apiKey": "pk_test_replace_me",
"events": [
{
"eventId": "9a7e5c3b-1d2f-4e6a-8b0c-2f4d6e8a0b1c",
"event": "$pageview",
"timestamp": "2026-09-30T10:00:00.000Z",
"distinctId": "anon_7d2f4c1a",
"sessionId": "sess_4b9e2d71",
"source": "browser",
"properties": {
"$pathname": "/pricing",
"$url": "https://www.example.com/pricing",
"$title": "Pricing",
"$consent_analytics": "granted",
"$consent_ad_user_data": "denied",
"$consent_ad_personalization": "denied",
"$consent_recorded_at": "2026-09-30T09:59:58.000Z",
"$consent_source": "cmp"
}
}
]
}Replace pk_test_replace_me with the project’s browser key.
Event fields
The Worker accepts at most 557,056 UTF-8 bytes (544 KiB) per request. It checks the body size before JSON parsing. A larger body answers 413with PAYLOAD_TOO_LARGE. The SDK sends at most 512 KiB per foreground request and 32 KiB per beacon.
The Worker splits valid events into queue messages of at most 120,000 serialized UTF-8 bytes. An enriched event that cannot fit alone appears in rejectedEvents with EVENT_TOO_LARGE and "retryable": false. Valid siblings can still be accepted.
| Field | Type | Rules |
|---|---|---|
eventId | string | Required, 1–128 characters. Keep it stable across retries. The same ID with a different payload is rejected with 409. |
event | string | Required, 1–100 characters. |
timestamp | string | Required UTC ISO 8601 time with a Z suffix, for example 2026-09-30T10:00:00.000Z. If it is more than 5 minutes ahead of, or more than 30 days behind, the time of receipt, reports use the receipt time and keep your value as the client timestamp. |
distinctId | string | Required, 1–490 characters without whitespace. The anonymous visitor ID. Must not be an email address or phone number. |
personId | string | Optional, 1–493 characters without whitespace. Same restrictions as distinctId. |
sessionId | string | Required, 1–500 characters. |
source | string | Required and must be "browser". Server-verified outcomes use the Conversion API instead. |
properties | object | Required. Must include the consent properties below. |
Consent properties
Every event carries the visitor’s consent state at the time of the event. An event without a complete snapshot is rejected.
| Property | Value |
|---|---|
$consent_analytics | granted, denied or unknown |
$consent_ad_user_data | granted, denied or unknown |
$consent_ad_personalization | granted, denied or unknown |
$consent_recorded_at | When the choice was recorded, as an ISO 8601 date |
$consent_source | A non-empty string, for example cmp |
Unless $consent_ad_user_data is granted, the Worker drops properties named after ad click identifiers (such as fbclid, fbp, fbc and gclid), removes those parameters from URL properties, and does not keep the request’s IP address or user agent for conversion matching. The SDK sends nothing until analytics consent is granted; follow the same rule when you call the endpoint directly. See Browser & Consent.
Property rules
- At most 200 keys and 50,000 bytes as JSON. Keys can be up to 500 characters and top-level string values up to 2,000 characters.
- A custom event must be in the project’s latest deployed schema. Required properties must be present, values must match their declared type or enum, and every property without a
$prefix must be declared. - Raw email addresses, phone numbers and payment card numbers are rejected anywhere in the properties, as are contact and credential keys such as
email,phone,password,authorizationor any key ending intokenorsecret.
Responses
Invalid events are rejected one by one, so valid events in the same request are still accepted. Staging and production queue accepted events and answer 202; the local profile delivers directly and answers 200 with "mode": "direct". A queued event is not yet visible in reports.
// 202 Accepted: queued (staging and production)
{
"success": true,
"batchId": "browser:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"batchIds": [
"browser:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
],
"accepted": 1,
"duplicatesInBatch": 0,
"duplicatesPreviouslyAccepted": 0,
"rejected": 0,
"rejectedEvents": [],
"mode": "queued",
"usageFinalization": "unmetered"
}
// 200 OK: every valid event was already accepted
{
"success": true,
"accepted": 0,
"duplicatesInBatch": 0,
"duplicatesPreviouslyAccepted": 1,
"rejected": 0,
"rejectedEvents": [],
"mode": "deduplicated"
}| Field | Meaning |
|---|---|
accepted | New events accepted from this request. |
batchIds | IDs of all queue messages admitted from this request. batchId is the first ID on a successful admission. |
duplicatesInBatch | Repeated copies of an event within this request. |
duplicatesPreviouslyAccepted | Events the project had already accepted. They are not stored or counted again. |
rejected | Count of invalid events. rejectedEvents lists each as { index, eventId, errors }. |
mode | queued, direct or deduplicated |
usageFinalization | committed (metered), unmetered, or pending when the events were accepted but their usage record was not finalised. Do not resend on pending. |
Errors
| Status | Body | When |
|---|---|---|
| 400 | "error": "Invalid JSON payload" or "error": "Validation failed" with details | The body is not JSON, has no events or more than 100, or every event is invalid. The last case also includes rejected and rejectedEvents. |
| 401 | Missing API key, Invalid API key format or Invalid API key | No key, a malformed key, or a key that is not registered to an active project in this environment. |
| 402 | "code": "EVENT_LIMIT_EXCEEDED" with projectId, period, limit, used and remaining | The account’s monthly event allowance is used up. Only the production deployment meters browser events. |
| 405 | Method not allowed | Any method other than POST or OPTIONS. |
| 409 | "code": "EVENT_ID_COLLISION" with eventIds | An eventId was already used for a different payload, in this request or an earlier one. Resending will not help. |
| 409 | "retryable": true and a Retry-After header | Another request is still processing the same eventIds. Retry after the given seconds. |
| 413 | PAYLOAD_TOO_LARGE with limitBytes | The request exceeds 557,056 UTF-8 bytes. Send smaller requests. |
| 429 | Rate limit exceeded and a Retry-After header | The public key or client IP exceeded its rate limit. IP limiting also reports IP_RATE_LIMIT_EXCEEDED. |
| 502 | "error": "Ingestion failed" with batchId and "retryable": true | Some messages could not be admitted. accepted and batchIds report the admitted events and messages; failedBatchIds lists the unsent messages. Retry the full request with unchanged eventIds. Committed events return as duplicatesPreviouslyAccepted. A non-retryable delivery failure answers 400 with "retryable": false. |
| 503 | ENVIRONMENT_CONFIGURATION_INVALID, DURABLE_GUARDS_REQUIRED, ATTRIBUTION_SYNC_REQUIRED, or a validation or usage service error | The Worker is misconfigured or a service it depends on is unavailable. Nothing was accepted; retry later with the same eventIds. |
Rate limit
The staging and production Workers apply Cloudflare’s rate limiter to each public key: 120 requests per 60 seconds, as configured in apps/ingest/wrangler.toml. A limited request answers 429 with Retry-After: 60. One request can carry up to 100 events. The local profile has no limit.
A second limiter permits 1,200 requests per 60 seconds for each client IP. It runs before body parsing and also covers consent withdrawals. Cloudflare supplies the client IP. Missing or malformed IPs share one fallback bucket. The larger allowance supports clients behind a shared NAT. CORS exposes Retry-After so the SDK can read the delay.
Browser consent withdrawal
When a visitor withdraws a purpose they had granted, the browser SDK sends one record to the same ingest URL before it removes its identifiers. You do not need to send it yourself. The SDK keeps the record and resends it unchanged until the Worker acknowledges or permanently rejects it, for up to 29 days. Also record every CMP decision and withdrawal through the server consent API described in Conversions.
{
"apiKey": "pk_test_replace_me",
"type": "consent_update",
"consentUpdate": {
"updateId": "5c8e2a1f-3b7d-4e9a-8c6f-1d2b3a4c5e6f",
"distinctId": "8b6e2f0a-4d1c-4e9b-9a7f-3c5d1e2b4a60",
"consent": {
"analytics": "denied",
"adUserData": "denied",
"adPersonalization": "denied",
"recordedAt": "2026-09-30T10:00:00.000Z",
"source": "cmp"
}
}
}- The key comes from the
apiKeyfield, as with the beacon envelope. Key checks are the same as for events. Withdrawals have their own key rate-limit bucket and share the client IP limiter with events. - A browser record can only withdraw. Granted values are ignored; a record with nothing withdrawn answers
"status": "ignored"and stores nothing. - Unknown fields are rejected.
updateIdis at most 128 characters and makes a resend idempotent.distinctIdmust be the SDK's random visitor ID (a UUID or 32 hex characters). A person ID is refused: a public key does not prove who sent the record. The withdrawal reaches an identified person through the visitor's consented identity links.recordedAtmust be within the last 30 days and at most 5 minutes ahead. The body is at most 4,096 bytes. - It never reaches the analytics warehouse and does not count as a tracked action.
// 200 OK
{
"success": true,
"status": "recorded",
"withdrawn": [
"analytics",
"adUserData",
"adPersonalization"
]
}| Status | Body | Meaning |
|---|---|---|
| 400 | INVALID_CONSENT_UPDATE | The record failed validation. |
| 401 | Invalid or unknown key | Same as for events. |
| 409 | CONSENT_UPDATE_CONFLICT | The updateId was already used for different content. |
| 410 | Project is unavailable | The project was deleted. |
| 429 | Rate limit exceeded and Retry-After | Same limit as events. |
| 502 | CONSENT_PERSISTENCE_FAILED | The record could not be stored. It can be retried with the same updateId. |
| 503 | CONSENT_PERSISTENCE_UNAVAILABLE | This Worker is not configured to store consent records. |
Dashboard query endpoint (app API)
POST $REALANALYTICS_DASHBOARD_URL/api/query
Used by the dashboard app. Requires an authenticated app session and project membership.
POST $REALANALYTICS_DASHBOARD_URL/api/query
Content-Type: application/json
Cookie: <session cookies>
{
"type": "timeseries",
"projectId": "my-project-slug",
"event": "$pageview",
"dateRange": {
"start": "2026-02-01T00:00:00.000Z",
"end": "2026-02-03T23:59:59.999Z"
},
"metric": "count",
"filter": {
"key": "$pathname",
"op": "eq",
"value": "/pricing"
}
}Query types: timeseries, breakdown, metric, funnel.
CLI-related app endpoints
POST /api/cli/validate- exchange one-time CLI token for access tokenPOST /api/projects- create project (session auth or CLI bearer token)POST /api/deploy- deploy compiled config (secret key + CLI/session auth)