A Mixpanel alternative for business metrics: events vs a custom metrics API

Event store, counter API, or SQL over your own database? What Infrai's /v1/analytics routes answer, what they refuse to, and when PostHog is the better call.

Pick the event store, not the counter. A dashboard that has to answer “how many invoices got paid last week, split by plan” needs one row per thing that happened, kept alongside the properties describing it — and Infrai’s analytics namespace is that store, reachable as plain REST from any backend. POST /v1/analytics/track writes an event, POST /v1/analytics/query/events reads a time window back, and the retention and path queries are free to run.

What Infrai doesn’t ship is a chart. No UI, no drag-and-drop explorer, no saved-report list — you get the store and the queries, and the dashboard itself is a page in your own admin app. That’s the trade-off the rest of this comparison turns on.

Three data shapes, and what each honestly answers

Mixpanel, Amplitude and PostHog are event stores with an analyst-facing UI bolted on. A custom metrics API is a counter: you decide at write time which question you’ll ask later, then read back an aggregate. Metabase and Redash are a third animal entirely — SQL clients pointed at a database you already run and already model.

The axis that decides it is whether the question exists before the data does.

Backend shape”How many paid invoices last week, by plan?""Which June signups came back?”New question needs a code changeTypical products
Event storeYes, from raw eventsYes, retention queryNo — properties are already storedMixpanel, Amplitude, PostHog, /v1/analytics/track
Custom metrics APIOnly if you bucketed by plan at write timeNoYes, a new metric name and a deployPrometheus, StatsD, /v1/metrics/report
SQL over your own DBYesYes, with a query you writeNo, but you own the schemaMetabase, Redash

A counter is cheaper and smaller, and it’s right for operational numbers whose shape never changes — queue depth, error rate, requests per second. Business questions mutate every quarter. Somebody will ask for the same number split by country, and with a counter the honest answer is “in three weeks, once we ship a new tag and accumulate data”.

What the analytics routes actually do

Five write routes and three read routes, no SDK involved. POST /v1/analytics/track takes one event and POST /v1/analytics/batch takes up to 1000 in a single call; POST /v1/analytics/identify merges profile traits onto a distinct_id, POST /v1/analytics/group attaches that id to a company for B2B rollups, and POST /v1/analytics/alias folds an anonymous id into a known one after signup.

Here’s the write, concrete enough to paste into a terminal:

curl -sS -X POST https://api.infrai.cc/v1/analytics/track \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H "content-type: application/json" \
  -d '{
    "event": "invoice_paid",
    "distinct_id": "kb_demo_user_7",
    "properties": {
      "kind": "invoice_paid",
      "plan": "pro",
      "amount_usd": 49,
      "tenant": "kb_demo_acme"
    }
  }'

The response carries an id you can log next to your own record:

{
  "ok": true,
  "data": { "accepted": true, "event_id": "evt_NNYd4Hrtqd4gRXtsdEMMUemh" },
  "metadata": {
    "request_id": "req_f85951d743e244858a3ada82",
    "latency_ms": 98,
    "vendor": "infrai",
    "cost_usd": 0.00005
  }
}

Event names are validated against ^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$, so "9 bad name!" comes back as a 400 with ANALYTICS_EVENT_NAME_INVALID — the behaviour you want, since a typo’d event name is silent data loss everywhere else. idempotency_key is honoured too: replay the same key and you get the same event_id back, billed once, which is what keeps a retrying webhook handler from double-counting revenue.

Reading back is a POST with an explicit window:

curl -sS -X POST https://api.infrai.cc/v1/analytics/query/events \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H "content-type: application/json" \
  -d '{
    "since": "2026-07-26T00:00:00Z",
    "until": "2026-07-27T00:00:00Z",
    "filter": {"tenant": "kb_demo_acme"},
    "limit": 50
  }'
{
  "ok": true,
  "data": {
    "items": [
      {
        "event": "invoice_paid",
        "distinct_id": "kb_demo_user_7",
        "properties": {"kind": "invoice_paid", "plan": "pro", "amount_usd": 49, "tenant": "kb_demo_acme"},
        "timestamp": "2026-07-26T05:49:17.568368Z"
      }
    ],
    "next_cursor": null,
    "total_estimate": 1
  }
}

Two details worth knowing before you design the property bag. filter is a property matcher, so every dimension you plan to slice by later — plan, tenant, country, and a kind key that mirrors the event name — belongs in properties at write time; that’s the one decision an event store still makes you get right up front. And total_estimate counts matching rows for the window, which makes a filter plus a limit of 1 a perfectly good counter tile: one free call, no scanning.

A dashboard tile, end to end

This is the whole pattern — page through a window, fold into whatever shape your admin page renders. Node 22, no dependencies:

// dashboard-tile.mjs — paid invoices in the last 7 days, grouped by plan
const API = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY in the environment");

async function queryEvents(payload) {
  const res = await fetch(`${API}/v1/analytics/query/events`, {
    method: "POST",
    headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
  const json = await res.json();
  if (!res.ok || json.ok !== true) {
    throw new Error(`query/events failed: ${json?.error?.code ?? res.status}`);
  }
  return json.data;
}

const since = new Date(Date.now() - 7 * 864e5).toISOString();
const until = new Date().toISOString();
const byPlan = new Map();
let cursor = null;
let scanned = 0;

do {
  const page = await queryEvents({ since, until, filter: { kind: "invoice_paid" }, limit: 1000, cursor });
  for (const item of page.items) {
    scanned += 1;
    const plan = item.properties?.plan ?? "unknown";
    byPlan.set(plan, (byPlan.get(plan) ?? 0) + 1);
  }
  cursor = page.next_cursor;
} while (cursor);

console.log(`paid invoices, last 7 days: ${scanned}`);
for (const [plan, n] of [...byPlan].sort((a, b) => b[1] - a[1])) {
  console.log(`  ${plan.padEnd(12)} ${n}`);
}

Cohort questions don’t need any of that folding, because there’s a route for them:

curl -sS -X POST https://api.infrai.cc/v1/analytics/query/retention \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H "content-type: application/json" \
  -d '{
    "born_event": "signup",
    "return_event": "invoice_paid",
    "since": "2026-06-01T00:00:00Z",
    "until": "2026-07-26T00:00:00Z",
    "interval": "week"
  }'

Each cohort comes back keyed by the day its born_event first fired, with a returning array of interval buckets. POST /v1/analytics/query/path is the sibling: give it a start_event and a max_depth and it returns a branching tree of what users did next, with a user count on every node.

What it costs, and how to check today’s number

Ingest is billed per call and reads are free. Verified 26 July 2026: analytics.track is $0.00005 per call, and the other four ingest routes — batch, identify, group, alias — are $0.0001 per call, so a 1000-event batch is one $0.0001 charge rather than a thousand. Queries are free but rate-limited, and new accounts start with $2 of free credit (roughly 39,999 track calls). Infrai’s rates drift downward and discount campaigns run, so treat those as a ceiling and read the live ones:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  | python3 -c "import json,sys; [print(c['method'], c['path'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('analytics.')]"

The structural point outlives any of those numbers: batching moves the unit from per-event to per-call, and reads never bill at all, so the cost of a dashboard is the cost of your writes.

Where a specialist beats this

Several places, and they’re not close.

If a product manager needs to build their own funnel without opening a terminal, stick with Mixpanel, Amplitude or PostHog — Infrai has no query builder, no session replay, and no funnel route at all (there’s an ANALYTICS_FUNNEL_STEP_INVALID error code, but nothing in the namespace emits it today), so a multi-step conversion funnel is something you fold together from query/events yourself. If your events already land in Postgres because they’re rows in your product schema, Metabase or Redash over a read replica is cheaper and more flexible than shipping the same facts twice. And a tile that pages through millions of rows on every render is a tile that gets slower every month — cache the fold, or keep a rollup table of your own, once a window stops fitting in a couple of pages.

What you get in exchange is one credential. The key that writes these events also runs the cron job that reads them at 08:00, stores the rendered CSV and emails it to finance — no second vendor, no second invoice, no second key rotation. For a small team whose analytics stack would otherwise be a fourth subscription with a seat price, that consolidation usually decides it, not the per-call rate.

References

Browse more analytics developer guides