# PostHog simulator

A local PostHog for agent tests: event capture over the ingestion API, with the project token in the JSON body.

`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](#limitations) names a gap. It never calls PostHog, so no test event reaches a real project.

## Quickstart

<!-- BEGIN generated: quickstart (go run ./ingest/cmd/twindocs posthog; do not edit) -->

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](https://www.rystic.ai/account/api-keys).

```bash
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](https://www.rystic.ai/docs/licensing)).

<!-- END generated: quickstart -->

### 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:

```bash
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](#auth) and [Error format](#error-format) list:

```bash
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:

```python
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](https://www.rystic.ai/docs/intercept.md), a client that keeps PostHog's host sends to PostHog.

<!-- BEGIN generated: known-clients (go run ./ingest/cmd/twindocs posthog; do not edit) -->

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. |

<!-- END generated: known-clients -->

## 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](#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](#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](#auth) lists |

A JSON array of events returns 400 `failed to parse request: …`, where PostHog accepts it ([Limitations](#limitations)). [An unserved route](https://www.rystic.ai/docs/control-plane.md#status-codes) returns the simulator's own 404.

### Rate limits

[Network profiles](https://www.rystic.ai/docs/network-profiles.md) 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.

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs posthog; do not edit) -->

<!-- coverage-claim -->
1 route, counted from the simulator's route table.

| Resource | Operations |
|---|---|
| Captured event (1) | `POST /i/v0/e/` |

<!-- END generated: served-operations -->

<!-- BEGIN generated: feed-effects (go run ./ingest/cmd/twindocs posthog; do not edit) -->

**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.

<!-- END generated: feed-effects -->

## Scenarios

<!-- BEGIN generated: scenarios (go run ./ingest/cmd/twindocs posthog; do not edit) -->

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:

```bash
rystic run posthog-twin -d --port 0 --scenario posthog_first_event
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"posthog_first_event"}'
```

<!-- END generated: scenarios -->

## 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:

```bash
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:

```bash
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](https://www.rystic.ai/docs/concepts/worlds.md#registers) lists the register's fields, and [Scenarios and seeding](https://www.rystic.ai/docs/concepts/worlds.md) 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](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Limitations

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs posthog; do not edit) -->

**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.

<!-- gaps-claim -->
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.

**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

<!-- END generated: known-gaps -->

Release notes: [Changelog](https://www.rystic.ai/docs/simulators/posthog/changelog.md)

---

Source: https://www.rystic.ai/docs/simulators/posthog · Markdown: https://www.rystic.ai/docs/simulators/posthog.md
