Skip to Content
SimulatorsPostHog

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.

ClientVersionKindWorks withFails withCheckedKnown gap
@posthog/cli (npm)v0.19.0cli—base URL2026-11-11The 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.7sdk—base URL2026-11-11The 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.0sdk—base URL2026-11-11The 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.5sdk—base URL2026-11-11The 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.29terraform-provider—base URL2026-11-11The 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:

RequestResponse
no api_key401 event submitted without an api_key
an empty api_key401 API key is not valid: empty
an api_key longer than 64 bytes in UTF-8401 API key is not valid: too_long
an api_key with a non-ASCII character401 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:

RequestResponse
a body that is not a JSON object400 failed to parse request: …
no distinct_id400 event submitted without a distinct_id
no event400 failed to hydrate events from request: non-engage request missing event name attribute
a missing or refused api_key401, 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
ResourceOperations
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.

ScenarioWhat it is forWhat it seedsClock
posthog_billing_backfillSix server-side billing events for two customers spread across five weeks of backfilled history, leaving one subscription active and one canceled.event 62026-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 12026-03-01T00:00:00Z
posthog_flag_experimentSeven events from a two-variant checkout experimentevent 72026-03-01T00:00:00Z
posthog_identify_handoffSix events tracing one visitor from an anonymous id to an identified userevent 62026-03-01T00:00:00Z
posthog_signup_funnelEight web events from three visitors on a signup funnelevent 82026-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:

FieldWhat it includes
uuidthe record’s key
eventthe event name
distinct_idthe person the event is about
propertiesthe event’s properties, as sent
timestampthe event’s time, or null
tokenthe 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 to POST /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 no Access-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 uuid is stored under the next id the simulator generates, and when an earlier event already used that id as its uuid, the new event replaces it. Workaround: Send your own uuid with every event, or none at all.
  • Two events sent to POST /i/v0/e/ with the same uuid and a different event keep only the second, where PostHog keeps both. Identical events with one uuid keep one, as on PostHog. Workaround: Send a fresh uuid with every distinct event.
  • An event captured at POST /i/v0/e/ without a timestamp is stored with timestamp set to null, where PostHog stamps the time it received the event. Workaround: Send a timestamp with 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

Last updated on