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.

EnvironmentIngest Worker$REALANALYTICS_INGEST_URL$REALANALYTICS_DASHBOARD_URL
Localrealanalytics-ingest-localThe 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
Stagingrealanalytics-ingest-stagingThe endpoint on the project’s Install page in the staging dashboard, or from the staging operator.https://app.staging.realanalytics.dev
Productionrealanalytics-ingestThe 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 with navigator.sendBeacon when 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.

FieldTypeRules
eventIdstringRequired, 1–128 characters. Keep it stable across retries. The same ID with a different payload is rejected with 409.
eventstringRequired, 1–100 characters.
timestampstringRequired 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.
distinctIdstringRequired, 1–490 characters without whitespace. The anonymous visitor ID. Must not be an email address or phone number.
personIdstringOptional, 1–493 characters without whitespace. Same restrictions as distinctId.
sessionIdstringRequired, 1–500 characters.
sourcestringRequired and must be "browser". Server-verified outcomes use the Conversion API instead.
propertiesobjectRequired. 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.

PropertyValue
$consent_analyticsgranted, denied or unknown
$consent_ad_user_datagranted, denied or unknown
$consent_ad_personalizationgranted, denied or unknown
$consent_recorded_atWhen the choice was recorded, as an ISO 8601 date
$consent_sourceA 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, authorization or any key ending in token or secret.

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"
}
FieldMeaning
acceptedNew events accepted from this request.
batchIdsIDs of all queue messages admitted from this request. batchId is the first ID on a successful admission.
duplicatesInBatchRepeated copies of an event within this request.
duplicatesPreviouslyAcceptedEvents the project had already accepted. They are not stored or counted again.
rejectedCount of invalid events. rejectedEvents lists each as { index, eventId, errors }.
modequeued, direct or deduplicated
usageFinalizationcommitted (metered), unmetered, or pending when the events were accepted but their usage record was not finalised. Do not resend on pending.

Errors

StatusBodyWhen
400"error": "Invalid JSON payload" or "error": "Validation failed" with detailsThe body is not JSON, has no events or more than 100, or every event is invalid. The last case also includes rejected and rejectedEvents.
401Missing API key, Invalid API key format or Invalid API keyNo 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 remainingThe account’s monthly event allowance is used up. Only the production deployment meters browser events.
405Method not allowedAny method other than POST or OPTIONS.
409"code": "EVENT_ID_COLLISION" with eventIdsAn 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 headerAnother request is still processing the same eventIds. Retry after the given seconds.
413PAYLOAD_TOO_LARGE with limitBytesThe request exceeds 557,056 UTF-8 bytes. Send smaller requests.
429Rate limit exceeded and a Retry-After headerThe 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": trueSome 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.
503ENVIRONMENT_CONFIGURATION_INVALID, DURABLE_GUARDS_REQUIRED, ATTRIBUTION_SYNC_REQUIRED, or a validation or usage service errorThe 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 apiKey field, 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. updateId is at most 128 characters and makes a resend idempotent. distinctId must 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. recordedAt must 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"
  ]
}
StatusBodyMeaning
400INVALID_CONSENT_UPDATEThe record failed validation.
401Invalid or unknown keySame as for events.
409CONSENT_UPDATE_CONFLICTThe updateId was already used for different content.
410Project is unavailableThe project was deleted.
429Rate limit exceeded and Retry-AfterSame limit as events.
502CONSENT_PERSISTENCE_FAILEDThe record could not be stored. It can be retried with the same updateId.
503CONSENT_PERSISTENCE_UNAVAILABLEThis 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 token
  • POST /api/projects - create project (session auth or CLI bearer token)
  • POST /api/deploy - deploy compiled config (secret key + CLI/session auth)

Next Steps