> ## Documentation Index
> Fetch the complete documentation index at: https://developers.autoplay.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# PostHog — How to setup

> Learn how to connect existing PostHog live user activity to your support AI agent using the Autoplay SDK.

## ⚡ Add this skill

<CardGroup cols={2}>
  <Card title="One command" icon="terminal">
    Add the Autoplay PostHog session replay provider skill for an existing PostHog setup.

    <CodeGroup>
      ```bash CLI theme={null}
      uvx --from autoplay-sdk autoplay-install-skills --user-activity posthog
      ```
    </CodeGroup>

    <a className="skill-card-link" href="/recipes/posthog/how-to-setup">View the docs →</a>
  </Card>

  <Card title="Agent onboarding" icon="robot">
    Fetch this skill when a customer already uses PostHog as a session replay provider and wants Autoplay live user activity.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -s https://developers.autoplay.ai/activity-posthog/SKILL.md
      ```
    </CodeGroup>

    <a className="skill-card-link" href="https://developers.autoplay.ai/activity-posthog/SKILL.md" target="_blank">View the skill →</a>
  </Card>
</CardGroup>

**Prerequisite:** install the SDK first — see [Quickstart](/quickstart).

<Tip>
  This guide assumes you **already have PostHog set up** and capturing events in your app — register using your existing PostHog project id as the **`product_id`**. If you don't have a PostHog project yet, see [PostHog's docs](https://posthog.com/docs/getting-started/install) to set one up first, then come back here.
</Tip>

**What Autoplay needs from your PostHog project:**

* **Project ID** — links your PostHog project to your Autoplay `product_id`. Find it in the URL while logged into your project (the numeric value following `/project/`), or under **Project Settings** in the sidebar.
* **Project API Key** (`phc_...`) — the public key your `posthog.init()` call already uses.
* **Personal API Key** (`phx_...`) — only needed if you want the SDK to create the webhook destination for you (Step 3, Option A).

### 🎯 Step 1 — Get credentials from your existing PostHog setup

Find your **Project ID** and **Project API Key** (Settings → Projects → \[Your Project] → General; the key starts with `phc_` — not the `phx_` Personal API Key, which `posthog.init()` rejects).

**📺 How to find your PostHog Project ID and Project API Key**

<iframe src="https://app.arcade.software/share/uQizURhG2MTNIxQjiIA1" title="Find your PostHog Project ID and Project API Key" width="100%" height="500" allow="fullscreen" style={{ borderRadius: "8px", border: "none" }} />

**Save your Project ID** — you will use it in Steps 2 and 3.

Your app should already have `posthog-js` installed and initialized — see [PostHog's library docs](https://posthog.com/docs/libraries) if you need to check the install/`init()` pattern for your framework (that page already shows the full `posthog.init(...)` call — no need to repeat it here). The one Autoplay-specific addition, inside your `init`'s `loaded` callback:

```javascript theme={null}
// Inside posthog.init(...)'s `loaded` callback.
// Must match onboard_product(product_id=...) in Step 2.
posthog.register({ product_id: 'YOUR_POSTHOG_PROJECT_ID' });
```

<Warning>
  **`api_host` must match the destination's region.** Whatever host your `posthog.init()` sends events to (`us.i.posthog.com` or `eu.i.posthog.com`) must be the same region you pass as `host` when registering the destination in Step 3 (`PostHogProvider.create_destination(host=...)`). If they don't match, nothing flows — PostHog itself has no way to warn you about this, since it's specific to how Autoplay provisions the destination.
</Warning>

**Required: identify users on login.** Your app most likely already calls `posthog.identify()` with **your own user id** somewhere in the login flow (never the anonymous `posthog.get_distinct_id()`) — see [PostHog's identify docs](https://posthog.com/docs/product-analytics/identify) if you need to check the general `identify()` / `reset()` pattern (that page already covers login/logout, traits, and why to avoid the anonymous id). The Autoplay-specific addition: include `product_id` in the identify traits (`email` is optional but recommended — it enables email-based scoping). This makes PostHog's `distinct_id` equal your app's user id — so the same id reaches Autoplay as `user_id` — and links earlier anonymous activity to the identified person:

```javascript theme={null}
// Inside your posthog.identify(user.id, { ...traits }) call, on login:
product_id: 'YOUR_POSTHOG_PROJECT_ID', // must match onboard_product(product_id=...) in Step 2
email: user.email, // optional — recommended, enables email-based scoping
```

<Warning>
  Don't call `posthog.identify(posthog.get_distinct_id(), …)` — that re-stamps
  the anonymous id and never sets a real user id. Always pass your app's stable
  user id. Until a user logs in they stay anonymous (that's expected); the
  `session_id` still scopes everything.
</Warning>

> **👋 Quick Tip:** Once you add this code to your site, jump into our [Discord](https://discord.gg/jCbR2tQA5) and say hi — we will check your data is flowing and help you get fully set up!

<Note>
  **Identity plumbing for widget-based support AI agents:** make sure the same user identity flows across all three layers: PostHog `distinct_id` / `user_id`, your chat widget session metadata, and the support AI agent backend sender identifier. If those do not match, chat replies will look like "no recent activity" because events are stored under one key and fetched with another.
</Note>

***

### 📝 Step 2 — Register your product with Autoplay

Now that your website is tracking clicks, we need to create a secure "ingest\_url" (**Webhook URL**) and a shared **secret** (`X-PostHog-Secret`) so that data can be safely sent to Autoplay.

The `autoplay-sdk` was installed on the [Quickstart](/quickstart) page. Create a Python file with the script below, replace the placeholders with your values from Step 1, and run it once:

```python theme={null}
import asyncio
from autoplay_sdk.admin import onboard_product
from autoplay_sdk.providers import PostHogProvider

async def main() -> None:
    result = await onboard_product(
        "YOUR_POSTHOG_PROJECT_ID",    # your PostHog project ID from Step 1
        contact_email="you@yourcompany.com",  # replace with your actual email
        user_activity_provider=PostHogProvider(),
        print_operator_summary=True,
    )

asyncio.run(main())
```

This will print the following fields:

* **product\_id:** `YOUR_POSTHOG_PROJECT_ID`
* **provider:** `posthog`
* **ingest\_url:** `https://connector.autoplay.ai/ingest/YOUR_POSTHOG_PROJECT_ID`
* **ingest\_secret:** `{secret}` — PostHog sends this as the `X-PostHog-Secret` header
* **mcp\_url:** `https://mcp.autoplay.ai/mcp`
* **mcp\_key:** `{secret}` — your agent's Bearer token

**Save what prints in the terminal** — you will need these values in Step 3 and Step 4:

* **Step 3 (PostHog webhook):** use the `ingest_url` and `ingest_secret` printed above
* **Step 4 (read live activity):** use the `mcp_url` and `mcp_key` printed above (`mcp_key` is your Bearer token)

<Note>
  **Re-registering your product**

  * A second `onboard_product` with the same `product_id` returns **409** until overwrite is allowed.
  * Pass **`force=True`**. You must still pass **`contact_email`** on every registration, including overwrites.
  * After a successful overwrite, the **`ingest_secret` rotates**. Update PostHog (**Step 3**) so **`X-PostHog-Secret`** matches the new secret.
</Note>

***

### 🔗 Step 3 — Set up your PostHog webhook

Now we must tell the website tracker (Step 1) to send its data to the secure address (webhook) you just generated (Step 2).

**You have three choices:**

**Option A — Automated with the SDK (recommended)**

Let the SDK create and verify the destination for you — no clicking around in
PostHog, no pasting Hog code. Run this once with the values from Step 2:

<Tip>
  **Where to find your PostHog Personal API Key:** In PostHog, go to **Settings → \[Your name] → Personal API keys → Create personal API key**. Give it `project:read` and `hog_function:write` permissions — see [PostHog's Personal API key docs](https://posthog.com/docs/api/personal-api-keys) if you need more detail. This is different from the Project API Key used in `posthog.init()`.
</Tip>

**📺 Generate your PostHog Personal API Key**

<iframe src="https://app.arcade.software/share/lOvmHy4FeRDtjD9xBTBb" title="Generate your PostHog Personal API Key" width="100%" height="500" allow="fullscreen" style={{ borderRadius: "8px", border: "none" }} />

```python theme={null}
import asyncio
from autoplay_sdk.providers import PostHogProvider

async def main() -> None:
    provider = PostHogProvider()
    dest = await provider.create_destination(
        host="https://us.posthog.com",             # your PostHog host (us.posthog.com or eu.posthog.com)
        project_id="YOUR_POSTHOG_PROJECT_ID",      # your project ID from Step 1
        personal_api_key="YOUR_PERSONAL_API_KEY",  # phx_... Personal API Key from the Tip above
        webhook_url="YOUR_INGEST_URL",             # ingest_url printed by Step 2
        webhook_secret="YOUR_INGEST_SECRET",       # ingest_secret printed by Step 2
    )
    status = await provider.verify(
        host="https://us.posthog.com",
        project_id="YOUR_POSTHOG_PROJECT_ID",
        personal_api_key="YOUR_PERSONAL_API_KEY",
        destination_id=dest.id,
    )
    print("destination", dest.id, "enabled:", status.ok)

asyncio.run(main())
```

It's **idempotent** — it creates the "Autoplay Event Stream" destination (or
updates it) and confirms it's enabled, so you can re-run it safely.

**Option B — Managed**

* Join our [Discord](https://discord.gg/jCbR2tQA5) and say hi.
* We configure the PostHog webhook for you.
* You receive a **1Password** link with your `ingest_url`, `ingest_secret`, and `mcp_key`.

**Option C — Fully manual (advanced)**

Add the destination by hand in PostHog — only needed if you can't run Option A:

* In PostHog, add a **Webhook** destination.
* **Webhook URL:** paste the `ingest_url` printed by Step 2.
* **`X-PostHog-Secret` header:** paste the `ingest_secret` printed by Step 2. Do **not** create a new secret.

PostHog still requires the form-level **Webhook URL** field even if your Hog source code also sets `let url := ...`. For the general mechanics of adding a webhook destination in PostHog's UI, see [PostHog's destinations docs](https://posthog.com/docs/cdp/destinations) — the Autoplay-specific part is the Hog source below, which shapes PostHog's event data into the payload Autoplay expects.

**PostHog webhook setup walkthrough**

<div
  style={{
position: "relative",
paddingBottom: "calc(52.9514% + 41px)",
height: 0,
width: "100%",
}}
>
  <iframe
    src="https://demo.arcade.software/EJMhT0lTf5CmhObVB4BE?embed&embed_mobile=tab&embed_desktop=inline&show_copy_link=true"
    title="Set Up a Webhook as a Data Destination"
    frameBorder={0}
    loading="lazy"
    allow="clipboard-write; fullscreen"
    style={{
  position: "absolute",
  top: 0,
  left: 0,
  width: "100%",
  height: "100%",
  colorScheme: "light",
}}
  />
</div>

Paste this into the **Event Body / Source** field of your PostHog webhook destination:

<AccordionGroup>
  <Accordion title="PostHog webhook — Hog source script (expand to copy)">
    ```text theme={null}
    fun extractFromElementsChain(str, pattern) {
        try {
            if (empty(str)) {
                return ''
            }
            let startIdx := position(str, pattern)
            if (startIdx <= 0) {
                return ''
            }
            let sub := substring(str, startIdx + length(pattern), length(str) - startIdx - length(pattern) + 1)
            let endIdx := position(sub, '"')
            if (endIdx > 0) {
                return substring(sub, 1, endIdx - 1)
            }
            return ''
        } catch (err) {
            print(f'extractFromElementsChain error for pattern {pattern}:', err)
            return ''
        }
    }


    let elements_chain := event.elements_chain ?? ''

    let element_id := ''
    let input_field_name := ''
    let link_destination := ''
    let button_or_link_text := ''

    try {
        element_id := extractFromElementsChain(elements_chain, 'attr__id="')
    } catch (err) {
        print('Error extracting element_id:', err)
        element_id := ''
    }
    try {
        input_field_name := extractFromElementsChain(elements_chain, 'attr__name="')
    } catch (err) {
        print('Error extracting input_field_name:', err)
        input_field_name := ''
    }
    try {
        link_destination := extractFromElementsChain(elements_chain, 'attr__href="')
    } catch (err) {
        print('Error extracting link_destination:', err)
        link_destination := ''
    }

    try {
        button_or_link_text := extractFromElementsChain(elements_chain, 'text="')
    } catch (err) {
        print('Error extracting button_or_link_text:', err)
        button_or_link_text := ''
    }

    let payload := {
        'event': event.event,
        'referrer': event.properties?.$referrer ?? '',
        'email': event.properties?.email ?? event.person?.properties?.email ?? '',
        'timestamp': event.timestamp ?? '',
        'element_id': element_id,
        'event_type': event.properties?.$event_type ?? '',
        'session_id': event.properties?.$session_id ?? '',
        'current_url': event.properties?.$current_url ?? '',
        'distinct_id': event.distinct_id ?? '',
        'elements_chain': elements_chain,
        'input_field_name': input_field_name,
        'link_destination': link_destination,
        'button_or_link_text': button_or_link_text
    }


    let headers := {
        'Content-Type': 'application/json',
        'x-posthog-secret': inputs.headers['x-posthog-secret']
    }

    let req := {
        'headers': headers,
        'body': jsonStringify(payload),
        'method': 'POST'
    }

    let url := inputs.url

    if (inputs.debug) {
        print('Request payload', payload)
        print('Request', url, req)
    }

    let res := fetch(url, req)


    if (res.status >= 400) {
        print('Webhook error response', res.status, res.body)
        throw Error(f'Webhook returned {res.status}: {res.body}')
    }
    if (inputs.debug) {
        print('Response', res.status, res.body)
    }
    ```
  </Accordion>
</AccordionGroup>

***

### 📡 Step 4 — See your activity land

Everything is wired up! The connector is **pull-based** — instead of streaming, you (or your agent) ask for a user's recent activity the moment you need it. Let's confirm your events are landing.

Click around your app while logged in as an identified user, then fetch that user's activity with the `mcp_key` you saved from Step 2:

```bash theme={null}
curl "https://mcp.autoplay.ai/users/YOUR_POSTHOG_PROJECT_ID/YOUR_USER_ID/live-activity?limit=10" \
  -H "Authorization: Bearer YOUR_MCP_KEY"
```

* `YOUR_USER_ID` — the stable id you pass to `posthog.identify(...)`.
* `YOUR_MCP_KEY` — the `mcp_key` printed by Step 2.

**What you'll get back** — the user's recent footsteps, ordered oldest → newest:

```json theme={null}
{
  "product_id": "YOUR_POSTHOG_PROJECT_ID",
  "user_id": "user_12345",
  "count": 3,
  "as_of": 1736940750.881,
  "actions": [
    { "type": "pageview", "title": "Page Load: Dashboard", "description": "User landed on the dashboard page", "canonical_url": "https://app.example.com/dashboard", "index": 0 },
    { "type": "click", "title": "Click Export Csv", "description": "User clicked the Export Csv button", "canonical_url": "https://app.example.com/dashboard", "index": 1 },
    { "type": "submit", "title": "Submit Payment Form", "description": "User submitted the Payment form", "canonical_url": "https://app.example.com/checkout", "index": 2 }
  ]
}
```

A **`200` with a populated `actions` array** means your events are flowing. An **empty array** means the user identified but hasn't browsed yet (activity is built from `$pageview` / `$autocapture`), or the events haven't landed yet — click around and give it a few seconds.

<Note>
  This REST call returns the **exact same data your agent reads** — the agent just pulls it over **MCP** (the `get_live_user_activity` tool) instead of curl. That's the next step.
</Note>

***

### 🔌 Next: connect your AI support agent

Your activity is now flowing into the connector. Head back to Quickstart to choose your existing AI support agent and connect it via MCP — it then pulls a user's live activity on demand, the moment it needs context to answer.

<Card title="Choose your AI support agent" icon="comments" href="/quickstart#-step-2-—-connect-your-ai-support-agent">
  Fin (Intercom), Maven, Ada, Botpress, and more — pick yours and connect via MCP.
</Card>

For structured logging and `extra` field conventions used across the SDK, see [Logging](/sdk/logging). Release history is on the [Changelog](/changelog).
