Simulators
PostHog simulator
posthog-twin is a local, stateful simulator of PostHog’s event capture for testing code that sends analytics events. It serves POST /i/v0/e/, the route on PostHog’s ingestion host that takes one JSON event, and stores the events it accepts, one per uuid, so your test can read them back. Expect PostHog’s own statuses and refusals everywhere except where a Differs from PostHog line or Limitations names a gap. It never calls PostHog, so no test event reaches a real project.
Quickstart
Install the CLI, sign in and boot the simulator with RYSTIC_URL pointing at it.
On Pro you sign in with an API key from your account .
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # your API key
export RYSTIC_URL=$(rystic run posthog-twin -d --port 0 --scenario posthog_first_event --json | jq -r .data.url)
curl -s "$RYSTIC_URL/_rystic/version"
# -> {..."product":"posthog-twin"...}
On Enterprise, the licensed edition signs in with a license file in place of the key: rystic login ./rystic-license.json (licensing ).
First calls
The default scenario has one signup event. Read the simulator’s state, capture an event with your project API key in the body, and read the state back. The simulator accepts a project key it does not know, as PostHog does, so the examples use a placeholder:
TOKEN=phc_rysticexampletoken0000000000000000000000
curl -s "$RYSTIC_URL/_rystic/inspect" | jq -c '[.state_event[] | {event, distinct_id}]'
# -> [{"event":"user_signed_up","distinct_id":"user_1"}]
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"api_key":"'"$TOKEN"'","event":"plan_upgraded","distinct_id":"user_1","properties":{"plan":"pro"}}'
# -> {"status":"Ok"}
curl -s "$RYSTIC_URL/_rystic/inspect" | jq -c '[.state_event[] | {uuid, event, timestamp}]'
# -> [{"uuid":"019ca35e-6b4c-7052-a8da-0af7a112f385","event":"user_signed_up","timestamp":"2026-02-28T08:30:00.012Z"},{"uuid":"5d91db16-a3e3-4800-8000-000000000001","event":"plan_upgraded","timestamp":null}]
Connect your client
PostHog’s libraries fail against the simulator, because each sends to a route it does not serve (the table below). Send each event from your own HTTP client instead. Post the JSON body you would send to https://us.i.posthog.com/i/v0/e/ to $RYSTIC_URL/i/v0/e/. A body the simulator refuses returns PostHog’s status and plain-text reason, as Auth and Error format list:
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"event":"checkout_started","distinct_id":"user_1"}' -w ' %{http_code}'
# -> event submitted without an api_key 401
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -d '{"event":"checkout_started","distinct_id":"user_1"}' -w ' %{http_code}'
# -> event submitted without an api_key 401
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"api_key":"","event":"checkout_started","distinct_id":"user_1"}' -w ' %{http_code}'
# -> API key is not valid: empty 401
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"api_key":"phc_0000000000000000000000000000000000000000000000000000000000000","event":"checkout_started","distinct_id":"user_1"}' -w ' %{http_code}'
# -> API key is not valid: too_long 401
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"api_key":"phc_café","event":"checkout_started","distinct_id":"user_1"}' -w ' %{http_code}'
# -> API key is not valid: not_ascii 401
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"api_key":"phx_personal","event":"checkout_started","distinct_id":"user_1"}' -w ' %{http_code}'
# -> API key is not valid: personal_api_key 401
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"api_key":"'"$TOKEN"'","event":"checkout_started"}' -w ' %{http_code}'
# -> event submitted without a distinct_id 400
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"api_key":"'"$TOKEN"'","distinct_id":"user_1"}' -w ' %{http_code}'
# -> failed to hydrate events from request: non-engage request missing event name attribute 400
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '[]' -w ' %{http_code}'
# -> failed to parse request: ... 400
Python, with the standard library:
import json
import os
import urllib.request
body = {"api_key": "phc_rysticexampletoken0000000000000000000000", "event": "checkout_started", "distinct_id": "user_1"}
request = urllib.request.Request(
os.environ["RYSTIC_URL"] + "/i/v0/e/",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
print(urllib.request.urlopen(request).read().decode())
Without the intercept gateway, a client that keeps PostHog’s host sends to PostHog.
Each client’s quickstart was run against this simulator in each way it connects, with the vendor’s own hosts blocked.
| Client | Version | Kind | Works with | Fails with | Checked | Known gap |
|---|---|---|---|---|---|---|
@posthog/cli (npm) | v0.19.0 | cli | — | base URL | 2026-11-11 | The simulator serves event capture at POST /i/v0/e/ only, so the official PostHog clients fail at their first call. The JavaScript, Node.js and Python libraries send to /e/, /batch/, /flags/ or /flags/definitions. The PostHog CLI reads /api/users/@me/ and /api/personal_api_keys/@current, and the Terraform provider reads a project’s feature flags. Workaround: Send each event to POST /i/v0/e/ as one JSON request from your own HTTP client, with your project API key in the body. |
posthog-js (npm) | v1.447.7 | sdk | — | base URL | 2026-11-11 | The simulator serves event capture at POST /i/v0/e/ only, so the official PostHog clients fail at their first call. The JavaScript, Node.js and Python libraries send to /e/, /batch/, /flags/ or /flags/definitions. The PostHog CLI reads /api/users/@me/ and /api/personal_api_keys/@current, and the Terraform provider reads a project’s feature flags. Workaround: Send each event to POST /i/v0/e/ as one JSON request from your own HTTP client, with your project API key in the body. |
posthog-node (npm) | v5.65.0 | sdk | — | base URL | 2026-11-11 | The simulator serves event capture at POST /i/v0/e/ only, so the official PostHog clients fail at their first call. The JavaScript, Node.js and Python libraries send to /e/, /batch/, /flags/ or /flags/definitions. The PostHog CLI reads /api/users/@me/ and /api/personal_api_keys/@current, and the Terraform provider reads a project’s feature flags. Workaround: Send each event to POST /i/v0/e/ as one JSON request from your own HTTP client, with your project API key in the body. |
posthog (PyPI) | v7.86.5 | sdk | — | base URL | 2026-11-11 | The simulator serves event capture at POST /i/v0/e/ only, so the official PostHog clients fail at their first call. The JavaScript, Node.js and Python libraries send to /e/, /batch/, /flags/ or /flags/definitions. The PostHog CLI reads /api/users/@me/ and /api/personal_api_keys/@current, and the Terraform provider reads a project’s feature flags. Workaround: Send each event to POST /i/v0/e/ as one JSON request from your own HTTP client, with your project API key in the body. |
posthog/posthog (Terraform) | v1.0.29 | terraform-provider | — | base URL | 2026-11-11 | The simulator serves event capture at POST /i/v0/e/ only, so the official PostHog clients fail at their first call. The JavaScript, Node.js and Python libraries send to /e/, /batch/, /flags/ or /flags/definitions. The PostHog CLI reads /api/users/@me/ and /api/personal_api_keys/@current, and the Terraform provider reads a project’s feature flags. Workaround: Send each event to POST /i/v0/e/ as one JSON request from your own HTTP client, with your project API key in the body. |
Auth
Send your project API key as api_key in the JSON body. The simulator reads no Authorization header, so a key sent only there returns the missing-key 401 (Connect your client). It accepts a project key it does not know, as PostHog does. It refuses a missing or malformed key with 401 and a plain-text reason, checking a present key in the table’s order:
| Request | Response |
|---|---|
no api_key | 401 event submitted without an api_key |
an empty api_key | 401 API key is not valid: empty |
an api_key longer than 64 bytes in UTF-8 | 401 API key is not valid: too_long |
an api_key with a non-ASCII character | 401 API key is not valid: not_ascii |
a personal API key (phx_) | 401 API key is not valid: personal_api_key |
API version
PostHog names the capture route’s version in its path, and the simulator serves /i/v0/e/ only. It reads no version header or parameter. The paths PostHog’s libraries send to, such as /e/ and /batch/, are not served (Limitations).
Errors and limits
Error format
A refusal returns a plain-text body (text/plain; charset=utf-8) with PostHog’s one-line reason, not JSON. The status matches PostHog’s. The reason text is copied from PostHog’s responses but is not checked against them, so assert on the status:
| Request | Response |
|---|---|
| a body that is not a JSON object | 400 failed to parse request: … |
no distinct_id | 400 event submitted without a distinct_id |
no event | 400 failed to hydrate events from request: non-engage request missing event name attribute |
a missing or refused api_key | 401, as Auth lists |
A JSON array of events returns 400 failed to parse request: …, where PostHog accepts it (Limitations). An unserved route returns the simulator’s own 404.
Rate limits
Network profiles send a 429 you shape, with the body and headers you choose. The simulator counts no requests and never returns 429 on its own.
Pagination
Not applicable: capture returns no list.
Idempotency
Not applicable: capture takes no idempotency key.
Coverage
The simulator serves PostHog’s single-event capture route on the ingestion host. PostHog’s other ingestion routes, its private API under /api/ and its query API are out of scope.
On 2026-11-11, the simulator matched PostHog on 9 of the 9 tests that compare the two (100%), and none of them was left without a verdict.
1 route, counted from the simulator’s route table.
Show all operations
| Resource | Operations |
|---|---|
| Captured event (1) | POST /i/v0/e/ |
Feed effects. No write emits a feed effect; watch requests instead. Each call appears on the event feed (/_rystic/events) as a request event, and reading the record back shows what it changed.
Scenarios
The simulator ships 5 scenarios; the quickstart boots posthog_first_event.
| Scenario | What it is for | What it seeds | Clock |
|---|---|---|---|
posthog_billing_backfill | Six server-side billing events for two customers spread across five weeks of backfilled history, leaving one subscription active and one canceled. | event 6 | 2026-03-01T00:00:00Z |
posthog_first_event (default) | A project holding exactly one event, a server-side signup sent by the Python library, so the effect of the next capture is easy to see. | event 1 | 2026-03-01T00:00:00Z |
posthog_flag_experiment | Seven events from a two-variant checkout experiment | event 7 | 2026-03-01T00:00:00Z |
posthog_identify_handoff | Six events tracing one visitor from an anonymous id to an identified user | event 6 | 2026-03-01T00:00:00Z |
posthog_signup_funnel | Eight web events from three visitors on a signup funnel | event 8 | 2026-03-01T00:00:00Z |
What each scenario holds:
posthog_flag_experiment: Four flag exposures split evenly between control and test, followed by three completed orders of which two are on the test variant.posthog_identify_handoff: Two anonymous pageviews, the identify event that links both ids, two product events under the user id and one group identify event.posthog_signup_funnel: One completes signup in four steps, one stops after starting the form and one leaves after a single pageview.
Boot one by name, or load one into a running simulator:
rystic run posthog-twin -d --port 0 --scenario posthog_first_event
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"posthog_first_event"}'
Seeding state
The simulator has one register, state_event, which keys each captured event by its uuid. A record includes the event as it was sent:
| Field | What it includes |
|---|---|
uuid | the record’s key |
event | the event name |
distinct_id | the person the event is about |
properties | the event’s properties, as sent |
timestamp | the event’s time, or null |
token | the API key the event was captured with |
Seed an event and read it back:
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_event":{"evt-trial-1":{"uuid":"evt-trial-1","event":"trial_started","distinct_id":"user_2","properties":{"plan":"pro"},"timestamp":"2026-02-28T09:00:00Z"}}}'
curl -s "$RYSTIC_URL/_rystic/inspect" | jq -c '.state_event["evt-trial-1"] | {event, distinct_id, timestamp}'
# -> {"event":"trial_started","distinct_id":"user_2","timestamp":"2026-02-28T09:00:00Z"}
A captured event is stored as it was sent, its timestamp included:
curl -s -X POST "$RYSTIC_URL/i/v0/e/" -H 'Content-Type: application/json' -d '{"api_key":"'"$TOKEN"'","event":"checkout_completed","distinct_id":"user_2","uuid":"evt-checkout-1","timestamp":"2026-02-28T10:15:00Z"}'
# -> {"status":"Ok"}
curl -s "$RYSTIC_URL/_rystic/inspect" | jq -c '.state_event["evt-checkout-1"] | {event, distinct_id, timestamp}'
# -> {"event":"checkout_completed","distinct_id":"user_2","timestamp":"2026-02-28T10:15:00Z"}
Registers lists the register’s fields, and Scenarios and seeding covers the seed response, refusals, boot files and the seed gate.
Determinism and reset
An event sent without a uuid is stored under the simulator’s next id, starting at 5d91db16-a3e3-4800-8000-000000000001. The simulator stores timestamp as the event sends it, and null when the event sends none.
Reset and determinism covers reset, the clock and the RNG.
Limitations
Known issues.
- The simulator serves event capture at
POST /i/v0/e/only, so the official PostHog clients fail at their first call. The JavaScript, Node.js and Python libraries send to/e/,/batch/,/flags/or/flags/definitions. The PostHog CLI reads/api/users/@me/and/api/personal_api_keys/@current, and the Terraform provider reads a project’s feature flags. Workaround: Send each event toPOST /i/v0/e/as one JSON request from your own HTTP client, with your project API key in the body. - Event capture at
POST /i/v0/e/returns 400 for a body that is a JSON array of events, where PostHog accepts it. It also accepts an event of 2 MB, where PostHog returns 413. Workaround: Send one event per request as a JSON object, and keep each event small. - A browser CORS preflight (
OPTIONS) to/i/v0/e/returns 404, and responses carry noAccess-Control-Allow-*headers, so a browser client on another origin cannot send an event that needs a preflight or read the response. Workaround: Send events from server-side code, which needs no preflight. - An event sent without a
uuidis stored under the next id the simulator generates, and when an earlier event already used that id as itsuuid, the new event replaces it. Workaround: Send your ownuuidwith every event, or none at all. - Two events sent to
POST /i/v0/e/with the sameuuidand a differenteventkeep only the second, where PostHog keeps both. Identical events with oneuuidkeep one, as on PostHog. Workaround: Send a freshuuidwith every distinct event. - An event captured at
POST /i/v0/e/without atimestampis stored withtimestampset tonull, where PostHog stamps the time it received the event. Workaround: Send atimestampwith every event.
Beyond the known issues, 9 of the 10 operations in the published spec are not served and 3 behaviors are not modeled. The details follow.
Show all limitations
Not served. 9 of the 10 operations in the published spec are not served.
- Not served at all: api (5), batch (1), e (1), flags (2)
Not modeled. Behavior the simulator does not model, by operation:
- Asynchronous ingestion: the person, the event definition and a new property’s definition appear on the private API 2 to 11 seconds after a 200. (
POST /i/v0/e/) - Content-Encoding: gzip request bodies (ev_wire_capture_11 sends an empty gzip body and is refused as unparsable). Whether a well-formed gzip body is decoded is untraced. (
POST /i/v0/e/) - … and 1 more
Release notes: Changelog