Migrating from PostHog, Segment or Statsig
Drop-in ingest endpoints, a posthog-js shim, and importers for history, flags and experiments.
There are three ways in, and you can combine them:
| What it does | Code changes | |
|---|---|---|
| Drop-in endpoints | Ingest accepts PostHog and Segment payloads. Point your existing SDKs at Atrix. | A host and a key |
@atrix.dev/posthog-compat | The posthog-js API on top of the native Atrix web SDK. | One import line |
atrix import | Brings historical events and persons, plus flags and experiments. | None |
The safest cut-over is a dual-write period: keep your current tool, add Atrix next to it, and compare daily event counts for a week before switching the old one off.
Drop-in endpoints
Everything goes to your region's ingest host, https://eu.i.analytics.atrix.dev for EU projects. The key is
always your Atrix public key (atx_pk_…), in the place the vendor SDK puts its own key.
| Vendor path | Accepts |
|---|---|
/capture, /i/v0/e, /e, /batch | PostHog events: a single event, { batch: [...] }, or a bare array (posthog-js). gzip, zstd, base64 and data= form bodies. |
/decide (?v=1 to 4), /flags?v=2 | PostHog flag evaluation, answered in PostHog's response shape |
/v1/track, /v1/identify, /v1/page, /v1/screen, /v1/group, /v1/alias, /v1/batch, /v1/import (and /v1/t, /i, /p, /s, /g, /a, /b) | Segment's HTTP API. The write key comes from HTTP Basic auth or writeKey in the body. |
/e and /batch are also the native Atrix endpoints: a body in which every event carries a lib object is
read as native, anything else as PostHog.
PostHog SDKs
Set the host to your region's ingest host and the key to your Atrix public key. For example, with posthog-js:
posthog.init("atx_pk_eu_production_XXXXXXXXXXXXXXXXXXXXXX", { api_host: "https://eu.i.analytics.atrix.dev" });and with posthog-node, posthog-python or any other PostHog SDK, the same two values (host / api_host and
the project key).
What is mapped:
distinct_id,$setand$set_once(top level or inside properties),$groups→ groups (up to 5),$session_id,$window_id, and$device_id/$anon_distinct_id→ the anonymous id used for merges.$identify,$create_alias,$groupidentify,$setand$pageviewkeep their meaning. Other unknown$-prefixed events are kept under a prefix, for exampleposthog:$survey_shown.- Event uuids are kept when they are already UUIDv7; otherwise a deterministic UUIDv7 is derived, so retries still deduplicate.
$ipis stripped, like every other IP address.- Dropped silently: PostHog session-recording snapshots (
$snapshot,$snapshot_items), heatmaps and performance events. Use Atrix session replay instead. /decideanswers with your Atrix flags and payloads; session recording, surveys, heatmaps and site apps are reported as off.- Merges follow Atrix identity rules: an alias that would join two identified users is refused.
Events arriving this way are tagged lib.name: "posthog-compat"; the original library name stays in $lib.
Segment
Set your Segment source's HTTP API host (or a Segment destination / analytics-node host option) to your
region's ingest host, and use your Atrix public key as the write key:
curl -X POST https://eu.i.analytics.atrix.dev/v1/track \
-u 'atx_pk_eu_production_XXXXXXXXXXXXXXXXXXXXXX:' \
-H 'Content-Type: application/json' \
-d '{ "userId": "user_42", "event": "Order Completed", "properties": { "revenue": 19.99 } }'| Segment call | Becomes |
|---|---|
track | the same event name (unknown $ names get a segment: prefix) |
identify | $identify with traits as person properties ($set when there is no userId) |
page | $pageview with URL, path, referrer, title, name and category |
screen | $screen with $screen_name |
group | $groupidentify for group type company |
alias | $create_alias, subject to Atrix identity rules |
The distinct id is userId, falling back to anonymousId. context fields map to OS, app version, locale and
library; context.ip is dropped. Events are tagged lib.name: "segment-compat".
@atrix.dev/posthog-compat
A drop-in replacement for the posthog-js module. Events go to Atrix through the native web SDK (not the compat endpoints), so identity, consent, flags and replay are Atrix's own.
// before: import posthog from "posthog-js";
import posthog from "@atrix.dev/posthog-compat";
posthog.init("atx_pk_eu_production_XXXXXXXXXXXXXXXXXXXXXX", {
capture_pageview: true,
persistence: "localStorage",
loaded: (ph) => ph.register({ app: "web" }),
});
posthog.identify("user_42", { plan: "pro" }, { first_seen: "2026-09-25" });
posthog.capture("signed_up", { source: "landing" });
posthog.group("company", "acme", { name: "Acme" });
const unsubscribe = posthog.onFeatureFlags((flags, variants) => {
if (posthog.isFeatureEnabled("new-checkout")) console.log(flags, variants);
});
const variant: boolean | string | undefined = posthog.getFeatureFlag("pricing");
await posthog.flush();
unsubscribe();Supported: init, capture (with $set, $set_once, timestamp, uuid), identify,
setPersonProperties and people.set / people.set_once, reset, register, register_once,
register_for_session, unregister, group, get_distinct_id, get_session_id, isFeatureEnabled,
getFeatureFlag, getFeatureFlagPayload, onFeatureFlags, reloadFeatureFlags, the opt-in/opt-out family,
startSessionRecording / stopSessionRecording, and flush. The native client is available as
posthog.atrix.
init options read: api_host, autocapture, capture_pageview, persistence, cookie_domain,
disable_persistence, disable_session_recording, opt_out_capturing_by_default, bootstrap
(feature flags and payloads), property_denylist, debug and loaded.
Safe no-ops: alias (identify instead), surveys, the toolbar, heatmaps, site apps and debug(). Nothing
throws.
atrix import
The atrix CLI imports history and configuration. Events keep their original ids and timestamps, so running
an import twice does not double count. Everything is batched, gzipped and retried.
Common options:
| Option | For | |
|---|---|---|
--key / ATRIX_PUBLIC_KEY | events | The target project's public key; events land in its environment. |
--capture-url / ATRIX_CAPTURE_URL | events | Defaults to the key's region. |
--dry-run, --out | all | Validate and write NDJSON (or print the plan) instead of sending. |
--project, --api-url / ATRIX_API_URL | flags, experiments | The project UUID and control plane, with ATRIX_TOKEN holding a token with flags:write and experiments:write. |
--enable-env | flags | Turn flags that were live at the source on in this environment. Otherwise they are created off. |
Pass --api-url https://app.analytics.atrix.dev (or set ATRIX_API_URL) for flags and experiments: the
CLI's built-in default is not the served host yet.
From PostHog
From export files:
export ATRIX_PUBLIC_KEY=atx_pk_eu_production_…
atrix import posthog --events events-2026-08.jsonl.gz --events events-2026-09.jsonl.gz \
--persons persons.csv --group-types 0=company,1=workspaceOr straight from the PostHog API, including flags and experiments:
export POSTHOG_PERSONAL_API_KEY=phx_…
export ATRIX_TOKEN=atx_pat_…
atrix import posthog --posthog-host https://eu.posthog.com --posthog-project 12345 \
--fetch events,persons,flags,experiments \
--project <atrix-project-uuid> --api-url https://app.analytics.atrix.dev --dry-runDrop --dry-run once the output looks right. Persons become person properties on their first distinct id, with
their other ids linked. Flags are created off and experiments as drafts; review them before launch.
Bucketing differs from PostHog's, so users are re-bucketed once when a flag goes live.
Input files can be JSON Lines, a JSON array, a PostHog API page ({ results: [...] }), a single object, or CSV
with a header row, optionally gzipped.
From Statsig
export ATRIX_TOKEN=atx_pat_…
atrix import statsig --gates gates.json --dynamic-configs dynamic_configs.json --experiments experiments.json \
--project <atrix-project-uuid> --api-url https://app.analytics.atrix.dev --dry-runThe files are Statsig Console API exports. Gates become boolean flags (rules become release conditions, pass percentages become rollouts), dynamic configs become multivariate flags whose variants carry the config values, and experiments become a multivariate flag plus a draft experiment with the first group as control. Rule conditions with no Atrix equivalent are skipped with a warning.
From Segment or June
atrix import segment --file archive-2026-08.jsonl.gz --file archive-2026-09.jsonl.gz --dry-run --out preview.ndjson
atrix import segment --file archive-2026-08.jsonl.gz --file archive-2026-09.jsonl.gzArchived Segment spec messages (and June exports) are mapped exactly as the live /v1/* endpoint maps them.
API mapping for hand-written wrappers
If you would rather move to the native SDK, the calls map one to one:
| PostHog | Atrix web SDK |
|---|---|
posthog.capture(event, props) | atrix.track(event, props) |
posthog.identify(id, set, setOnce) | atrix.identify(id, set, setOnce) |
posthog.reset() | atrix.reset() |
posthog.group(type, key, props) | atrix.group(type, key, props) |
posthog.register(props) | atrix.register(props) |
posthog.isFeatureEnabled(key) | atrix.isEnabled(key) |
posthog.getFeatureFlag(key) | atrix.getVariant(key) |
posthog.getFeatureFlagPayload(key) | atrix.getPayload(key) |
posthog.onFeatureFlags(cb) | atrix.onFlags(cb) |
posthog.opt_out_capturing() | atrix.optOut() |
import * as atrix from "@atrix.dev/analytics-web";
type Props = Record<string, string | number | boolean | null | undefined>;
atrix.init("atx_pk_eu_production_XXXXXXXXXXXXXXXXXXXXXX", { superProperties: { environment: "production" } });
export const track = (event: string, props?: Props) => atrix.track(event, props);
export const identifyPlayer = (p: { id: string; city?: string | null; level?: number | null }) =>
atrix.identify(p.id, { city: p.city, level: p.level });
export const resetAnalytics = () => atrix.reset();