Developers

First event in under an hour. Schema enforced from day one.

Typed SDKs for every surface, a REST API for everything the dashboard can do, signed webhooks, and a tracking plan and journeys that live in your repository and ship through CI.

live · project edvK3P9QW2AX streaming
event nameplatform: allstatus: allkey: sdk_prod
  • checkout_started10:42:07u_5f2aiosvalid
  • booking_confirmed10:42:05u_31c8androidvalid
  • refund_issued10:42:03srv_billingservervalid
  • session_started10:42:01u_88e3webvalid
  • product_added10:41:59u_5f2aiosquarantined
  • payment_failed10:41:57u_4a10iosvalid
checkout_startedvalid · written
{
  "event": "checkout_started",
  "user_id": "u_5f2a",
  "platform": "ios",
  "received": "10:42:07",
  "properties": {
    "cart_total": 2490,
    "items": 3,
    "currency": "INR"
  }
}
events 1,284 · quarantined 34 platforms · schema v12 · last hour

Quickstart

Install, identify, track.

Every SDK shares one core: an event queue that survives restarts, consent gating, identity that stitches anonymous history to a signed-in user, and the same wire format the server API accepts.

One package, one init. Events queue in the browser, flush in batches and survive a reload.

terminal
npm install @edverix/analytics-web
src/analytics.ts
import { Analytics } from "@edverix/analytics-web";

const analytics = new Analytics({
  apiKey: "edverix_live_XXXXXXXXXXXXXXXX",
  endpoint: "https://api.edverix.com",
  automaticTracking: true, // page views, sessions, flush on hide
});

await analytics.init();

// Called once the person signs in; earlier anonymous events are stitched.
analytics.identify("u_5f2a", { plan: "growth", city: "Pune" });

analytics.track("product_viewed", { sku: "DR-1044", price: 2490 });

// Ecommerce helper: standard events land in the revenue reports as-is.
analytics.ecommerce.orderCompleted({
  orderId: "ord_88e3",
  revenue: 1290,
  currency: "INR",
  products: [{ sku: "DR-1044", quantity: 1, price: 1290 }],
});

Route through a hostname you own (a CNAME to api.edverix.com) to keep the SDK first-party.

SDK matrix

The same capabilities on every platform.

Client SDKs carry the queue, consent and lifecycle logic. The server API is the same endpoint the SDKs call, with nothing hidden behind them.

PlatformOffline queue and dedupeConsent gatingAutomatic lifecycle eventsPush token registrationIn-app renderingApp inboxUninstall detectionFirst-party domain
Web@edverix/analytics-webYesYesSessions, page viewsWeb pushYesYesNoYes
React@edverix/analytics-reactYesYesSessions, page viewsWeb pushYesYesNoYes
React Native@edverix/analytics-react-nativeYesYesYesFCM, APNsYesYesYesYes
iOSEdverix (Swift package)YesYesYesAPNsYesYesYesYes
Androidcom.edverix:analytics-androidYesYesYesFCMYesYesYesYes
Flutteredverix_analyticsYesYesYesFCM, APNsYesYesYesYes
Server (HTTP)POST /v1/eventsDedupe on event_idYour consent storeNoPOST /v1/devicesNoNoPOST /v1/devices/uninstalledAny hostname

First-party domain: events are sent to a hostname you own. Uninstall detection uses a silent push through your own Firebase or APNs credentials.

REST API

Everything the dashboard does, you can do over HTTPS.

One base URL, JSON in and out, keyset pagination, and the same tenant scoping on every call: an organisation cannot name another organisation's data, and a request for it is refused rather than filtered.

https://api.edverix.com

  • POST
    /v1/events

    Ingest a batch of events; deduplicated on event_id.

  • POST
    /v1/devices

    Register a push token against a profile.

  • POST
    /v1/devices/uninstalled

    Report an uninstall from your own push pipeline.

  • GET
    /v1/events

    Query events with property filters and keyset paging.

  • GET
    /v1/event-users/:id/timeline

    One person's events, sessions and messages in order.

  • POST
    /v1/segments

    Create a segment from a filter tree or a SQL query.

  • POST
    /v1/segments/:id/evaluate

    Membership for one user, or a count with a breakdown.

  • POST
    /v1/funnels/query

    Run a funnel over any window with a breakdown property.

  • POST
    /v1/journeys/:id/simulate

    Dry-run a journey against history; nothing is sent.

  • POST
    /v1/messages/transactional

    Send a templated message to one person, outside journeys.

  • POST
    /v1/exports

    Export events or profiles as JSON, CSV or Parquet.

GET /v1/events
curl -G "https://api.edverix.com/v1/events" \
  -H "Authorization: Bearer edverix_live_XXXXXXXXXXXXXXXX" \
  -H "x-project-id: prj_k3p9qw2ax" \
  --data-urlencode "event_name=checkout_started" \
  --data-urlencode "from=2026-09-01T00:00:00Z" \
  --data-urlencode "filters@filters.json"
filters.json
[
  { "key": "cart.total", "operator": "gte",        "value": 500 },
  { "key": "coupon",     "operator": "not_exists" },
  { "key": "platform",   "operator": "in",         "value": ["ios", "android"] }
]

Fourteen operators, each bound as a parameter. Dotted keys address nested properties.

  • eq
  • ne
  • gt
  • gte
  • lt
  • lte
  • contains
  • not_contains
  • starts_with
  • ends_with
  • exists
  • not_exists
  • in
  • not_in

Authentication

SDK keys
Sent as X-API-Key. Write-only: they can post events and devices and nothing else, so they are safe to embed in a client app. Shown once at creation and stored as a peppered hash.
Service accounts
Sent as Authorization: Bearer for query, management and export calls. Each account holds a primary and a secondary key so rotation never has a gap, and scopes limit what it may touch.
Project header
x-project-id selects the project inside your organisation. SDK keys are bound to one project already; service accounts name it on every call.

Webhooks

Signed, retried, circuit-broken.

Subscribe an HTTPS endpoint to the events you care about. Each delivery is signed with your secret, retried on failure, and paused rather than hammered when your endpoint is down.

What is sent

Pick any subset per endpoint. Message events carry the provider response; journey and segment events carry the person and the reason.

  • message.sent
  • message.delivered
  • message.failed
  • message.opened
  • message.clicked
  • message.bounced
  • journey.entered
  • journey.exited
  • segment.entered
  • segment.exited
  • device.uninstalled
  • profile.merged
  • export.completed
  • plan.violation
delivery · message.delivered
POST /hooks/edverix HTTP/1.1
Host: app.example.com
Content-Type: application/json
X-Edverix-Event: message.delivered
X-Edverix-Delivery: whd_01J8Q4M3ZK7N2R9V6T1B
X-Edverix-Timestamp: 1790327647
X-Edverix-Signature: v1=7f3c2a1b9e4d8c6f0a5b3e2d1c9f8a7b6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b

{
  "id": "whd_01J8Q4M3ZK7N2R9V6T1B",
  "type": "message.delivered",
  "occurred_at": "2026-09-25T09:14:07Z",
  "project_id": "prj_k3p9qw2ax",
  "data": {
    "message_id": "msg_31c8",
    "channel": "whatsapp",
    "user_id": "u_5f2a",
    "journey_key": "cart_recovery",
    "template": "cart_offer_10",
    "provider": "meta_cloud_api"
  }
}
verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyEdverix(rawBody: string, headers: Headers, secret: string): boolean {
  const ts = headers.get("x-edverix-timestamp") ?? "";
  const given = (headers.get("x-edverix-signature") ?? "").replace(/^v1=/, "");

  // Reject anything older than five minutes to stop replays.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(given, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Signed

Every delivery carries X-Edverix-Signature: HMAC-SHA256 over the timestamp and raw body with your endpoint's secret. Rotate the secret from the dashboard; the previous secret keeps verifying for 24 hours.

Retried

A non-2xx response or a 10-second timeout schedules a retry with exponential backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, then daily for 3 days. Delivery is at-least-once; deduplicate on the delivery id.

Circuit-broken

After 20 consecutive failures the endpoint is paused and project owners are notified. Deliveries queue for 7 days. Re-enable the endpoint, or fix it and let the health check re-enable it, and the queue replays in order.

Tracking plan as code

The schema lives in your repository and runs in CI.

Declare every event and property once. The CLI checks production traffic against the plan, generates types for the SDK, and quarantines events that break the contract instead of letting them poison a report.

tracking-plan.yaml
# tracking-plan.yaml
version: 1
project: prj_k3p9qw2ax

events:
  checkout_started:
    description: A person opens the payment step with a non-empty cart.
    owner: growth-team
    sources: [web, ios, android]
    properties:
      cart_total:  { type: number,  required: true, min: 0 }
      currency:    { type: string,  required: true, enum: [INR, USD, SGD] }
      item_count:  { type: integer, required: true }
      coupon:      { type: string,  required: false, pattern: "^[A-Z0-9]{4,12}$" }
    unknown_properties: quarantine

  order_completed:
    description: Payment captured and the order id issued.
    properties:
      order_id:    { type: string, required: true }
      revenue:     { type: number, required: true, min: 0 }
      currency:    { type: string, required: true, enum: [INR, USD, SGD] }
ci · edverix plan check
$ edverix plan check --plan tracking-plan.yaml --against production --since 7d

  checkout_started
    x  cart_total   sent as string "2490" in 1,204 events        web 2.3.1
    x  currency     value "inr" not in enum [INR, USD, SGD]      ios 4.12.0

  2 violations found  ·  1 quarantined (currency)  ·  1 coerced (cart_total)

  Quarantined events are held outside reports and journeys.
  Fix the source, then: edverix plan replay checkout_started --since 7d
edverix-events.d.ts
// Generated by `edverix plan types`. Do not edit.
export interface CheckoutStarted {
  cart_total: number;
  currency: "INR" | "USD" | "SGD";
  item_count: number;
  coupon?: string;
}

export interface OrderCompleted {
  order_id: string;
  revenue: number;
  currency: "INR" | "USD" | "SGD";
}

export type TrackedEvents = {
  checkout_started: CheckoutStarted;
  order_completed: OrderCompleted;
};

declare module "@edverix/analytics-web" {
  interface Analytics {
    track<E extends keyof TrackedEvents>(name: E, properties: TrackedEvents[E]): void;
  }
}

Quarantine and replay

An event that violates its definition is stored in full but held outside reports, segments and journeys, so a malformed release cannot trigger a campaign or move a funnel. Coercible violations, such as a number sent as a string, are corrected on the way in and flagged for the owner. When the source is fixed, one command replays the quarantined window through the plan and everything that now validates takes its place in history at its original timestamp.

Journeys as code

Define a journey in YAML. Promote it like a deploy.

The canvas and the file are two views of the same definition. Diff a change, simulate it against real history, collect the approval the governance rules require, then promote. Nothing is sent until every gate passes.

journeys/cart-recovery.yaml
# journeys/cart-recovery.yaml
key: cart_recovery
name: Cart recovery

trigger:
  event: checkout_started
  where: { cart_total: { gte: 500 } }
  exit_on: [order_completed]
  once_per: 7d

governance:
  frequency_cap: default
  quiet_hours: user_timezone
  blast_radius: 5000/day
  holdout: universal

steps:
  - wait: 45m
  - send: { channel: push, template: cart_reminder_v3 }
  - wait: 6h
  - branch:
      on: opened(cart_reminder_v3)
      yes:
        - send: { channel: whatsapp, template: cart_offer_10 }
      no:
        - send: { channel: email, template: cart_reminder_email }
  - wait: 24h
  - exit
terminal
$ edverix journeys diff cart_recovery --env production

  ~ steps[1].send.template    cart_reminder_v2 -> cart_reminder_v3
  + steps[3].branch           on opened(cart_reminder_v3)
  ~ governance.blast_radius   2500/day -> 5000/day

$ edverix journeys promote cart_recovery --to production

  Simulating against the last 30 days of production events
  would enter 4,318 people · 212 stopped by cap · 96 deferred by quiet hours · 43 held out

  governance.blast_radius changed: approval required (journeys:approve)
  Promotion queued · prm_01J8Q4M3ZK7N2R9V6T1B · nothing is sent until approved

The simulation summary is the same one the dashboard shows before a journey goes live. A blast-radius change always requests approval from a role that holds the journeys:approve permission.

How journeys, simulation and approval fit together

Data model

Two kinds of people, one tenant boundary.

Your organisation owns projects, keys and the people who sign in to the dashboard. Your apps write end-users and events into a separate store that is scoped to the project on every row.

CONTROL PLANE · WHO MAY DO WHATDATA PLANE · WHAT YOUR APPS SENDOrganisationkyc-verified · plan · quotaDashboard usersusers · roles · sso · scimProjectsprod · staging · one per appAPI keyssdk · service accountsevent_usersyour end-users · never sign ineventspartition 2026-07partition 2026-08partition 2026-09users and event_users are different tables. every row of tenant data carries organisation_id and project_id.
People who sign in to the dashboard and people your apps track live in different tables with different identifiers. Events are range-partitioned by occurred_at, so retention drops a partition instead of deleting rows, and idempotency is enforced on (project, event_id, occurred_at).

Dashboard users and tracked end-users are different tables. An end-user never authenticates against Edverix, their identifiers are chosen by you, and the same email can be two unrelated people under two organisations.

FAQ

Questions engineers ask first.

Does the web SDK work with server-side rendering?

Yes. The SDK touches window and document only inside init and the page helper, so importing it in a Next.js, Remix or TanStack Start module is safe. Call init on the client, typically in a provider effect; calls made before init resolves are buffered and replayed with their original timestamps.

What about ad blockers and tracking prevention?

Point the endpoint option at a hostname you own that forwards to api.edverix.com. The SDK then makes first-party requests with no third-party cookies, which is how it stays outside the lists blockers ship. Mobile SDKs are unaffected.

How are events batched and what happens offline?

Events queue in local storage on web and in AsyncStorage on React Native, flush every five seconds or at ten events by default, and flush again when the page hides or the app backgrounds. A failed flush keeps the batch and retries on the next cycle. Nothing is lost on a reload or a cold start.

Is ingestion idempotent?

Yes. Every event carries an event_id generated by the SDK, or supplied by you on the server API. Batches are deduplicated on that id per project, so at-least-once delivery and client retries never double count a conversion or fire a journey twice.

What do you do with personal data inside event properties?

Properties are sanitised at ingestion: values that look like passwords, card numbers or tokens are replaced with a redaction marker before anything is stored. Fields you mark as PII in the tracking plan are stored masked and unmasked only for a specific permission. Erasure requests remove a person end to end.

Can we self-host the platform?

Yes, on the Enterprise plan. The same dashboard, API, SDKs and worker run in your own cloud account and region, against a Postgres you control. Upgrades ship as versioned images and support covers the deployment as it would the hosted service.

Get started

Send your first event today.

Send your first event in an hour, simulate your first journey against real history, and keep every byte of it in a database you can query.

  • No credit card for the Developer plan
  • Bring your own providers
  • Export everything, any time