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

# PostHog (product events)

> Stream product-usage events from PostHog into Zudo so account engagement, indicators, and health scores reflect real product activity.

The PostHog integration ingests product-usage events into Zudo as a webhook destination. Events flow into account timelines, daily counts charts, [Indicators](/accounts/indicators), and (optionally) [health scores](/accounts/health-scoring) — without writing custom code.

<Note>
  This integration is **org-level**. Connections are scoped to your organization and managed by an owner. If you don't
  see **Add Connection**, ask your org owner to set this up.
</Note>

<Note>
  Zudo also has a separate **per-user PostHog integration** under **Settings → Integrations** that pulls person and
  group properties via HogQL. The product-events connection described here is a different feature — it *receives* live
  events from PostHog instead of querying them on a schedule. The two can coexist.
</Note>

## Before you start

You'll need:

* An **owner** role in your Zudo organization
* A PostHog project actively capturing events
* (Recommended) a PostHog **group type** like `organization` or `company` that maps to one of your customer accounts. Without group context, Zudo can only resolve events to accounts via user email or contact-level matching.
* (Optional but strongly recommended) a PostHog **personal API key** with read access to your project. With it, Zudo can list distinct event names from your project so you can pick the allowlist from a checkbox-list instead of typing event names by hand. You can add this when creating the connection or any time after.

## How it works

1. Zudo gives you a unique webhook URL and a bearer token.
2. You add a webhook destination in PostHog that posts each event to the URL with `Authorization: Bearer <token>`.
3. PostHog delivers events in near real time.
4. Zudo verifies the bearer token, archives the raw event to S3, and inserts a row into the staging table.
5. Every 10 minutes a job aggregates allowlisted events into per-account daily rollups and resolves which Zudo account each event belongs to.

## Connect

<Steps>
  <Step title="Create the Connection in Zudo">
    Go to **Settings → Connections**, click **Add Connection**, and select **PostHog (product events)**.

    Optionally fill in the **PostHog query API** fields with your host (e.g. `https://us.posthog.com`), project ID
    (visible in your PostHog URL — `/project/<id>/...`), and a personal API key. These let Zudo discover event names
    from your project so the allowlist becomes a checkbox-list. You can also leave them empty and add them later.

    Click **Create Connection** — Zudo generates the webhook URL and bearer token automatically.
  </Step>

  <Step title="Copy the webhook URL and bearer token">
    The next screen shows both values once. **Copy them now** — Zudo will not show the token again. If you lose it, you
    can delete the connection and recreate it.
  </Step>

  <Step title="Add the webhook destination in PostHog">
    The exact path depends on your PostHog version. PostHog Cloud (current) uses **Hog Functions** for outbound webhooks:

    1. Open **Data pipeline → Destinations** in your PostHog project.
    2. Click **New destination** and search for **HTTP Webhook** (or just **Webhook**).
    3. **URL**: paste the URL Zudo gave you.
    4. **Method**: `POST`.
    5. **Headers**: add a custom header
       * Name: `Authorization`
       * Value: `Bearer <your token>` (paste the token Zudo gave you, prefixed with `Bearer ` and a space)
       * Add another:
       * Name: `Content-Type`
       * Value: `application/json`
    6. **JSON Body**: paste this exactly. Each `{...}` is a Hog expression that PostHog evaluates against the event being
       delivered. Zudo reads `event`, `distinct_id`, `timestamp`, and `properties` (for `$groups` and `email`) out of the
       payload.

       ```json theme={null}
       {
         "event": "{event.event}",
         "distinct_id": "{event.distinct_id}",
         "timestamp": "{event.timestamp}",
         "properties": "{event.properties}",
         "person": "{person}"
       }
       ```

           <Note>
             `{event.properties}` and `{person}` are intentionally wrapped in quotes — Hog Functions sends them as
             JSON-encoded strings inside the JSON body. Zudo's normalizer parses them back to objects on receipt, so
             `$groups`, `email`, and other nested fields are all accessible. The `event.` prefix is required on most fields
             because `event.event`, `event.distinct_id`, etc. are how the Hog runtime exposes them; bare `{distinct_id}` will
             fail with "Could not execute bytecode for input field".
           </Note>
    7. **Filters**: optionally limit which events are sent. If you don't filter here, Zudo's allowlist will filter on the
       receiving side — both work, but filtering at PostHog reduces network traffic.
    8. Save and enable the destination.

    <Tip>
      Self-hosted or older PostHog versions: use the **Webhook** plugin under **Apps**. The fields are equivalent — set
      the URL, add the `Authorization: Bearer <token>` header, and enable the plugin.
    </Tip>
  </Step>

  <Step title="Configure the allowlist back in Zudo">
    Go back to **Settings → Connections** and expand the new PostHog connection. Under **Event allowlist**:

    * **With the PostHog query API configured**: Zudo shows a checkbox-list of the top 200 distinct event names seen in
      your project over the last 30 days, with their volume counts. Tick the events you want rolled up. Use the
      **Add a custom event name…** input to include events that haven't fired in the last 30 days or aren't yet in
      PostHog.
    * **Without the API configured**: a free-text textarea (one event name per line). You can switch to the checkbox-list
      any time by adding the API key under the **PostHog query API** section on the connection card.

    Anything outside the allowlist is still archived to S3 (replayable later) but won't appear in rollups, indicators, or
    health scores.
  </Step>

  <Step title="Set the group key">
    Under **Identity rules**, set the **Group key** to the PostHog group type that identifies your accounts (for example
    `organization` or `company`). Zudo reads the matching value from `properties.$groups[<groupKey>]` on incoming events
    to resolve them to a Zudo account.
  </Step>

  <Step title="Configure identity rules (optional)">
    The defaults work for most setups. Customize the chain if your account/contact identifiers live somewhere
    non-standard — see [Identity rules](#identity-rules) below.
  </Step>

  <Step title="Verify">
    Trigger a test event from your app. Within \~10 minutes you'll see daily counts on account pages, and recent events
    appear in the activity timeline under the **Events** filter.
  </Step>
</Steps>

## What flows in

PostHog sends one event per webhook delivery. Zudo extracts:

| PostHog field                     | Used for                                              |
| --------------------------------- | ----------------------------------------------------- |
| `event`                           | The event name. Compared against your allowlist.      |
| `distinct_id`                     | The user identifier (`externalUserId` in Zudo).       |
| `properties.$groups[<groupKey>]`  | The account identifier (`externalGroupId` in Zudo).   |
| `properties.email` / `$set.email` | Contact email, used for fallback identity resolution. |
| `timestamp`                       | When the event occurred.                              |
| Full payload                      | Archived to S3 in gzipped JSONL form for replay.      |

## Identity rules

When a PostHog event arrives, Zudo runs an ordered chain of strategies to figure out which Zudo Account (and optionally Contact) the event belongs to. The first match wins.

The **default** chain looks like this:

* **Account chain**:
  1. Match `$groups[<groupKey>]` against `Account.externalId` (the canonical external identifier you've stamped on the account in Zudo)
  2. Match `$groups[<groupKey>]` against `Account.psId` (your existing internal account id)
  3. Match `$groups[<groupKey>]` against `Account.vitallyId` (if you've also got Vitally synced)
* **Contact chain** (used when no account match was found):
  1. Match `distinct_id` against `Contact.productExternalUserId`
  2. Match `email` against `Contact.email`

If neither chain hits, the event is still archived and rolled up — just attached to a `null` accountId. Once you map the right ID onto the account, use **Reconcile** on the connection card to back-fill.

### Customize

In the connection's **Identity rules** section you can:

* Reorder strategies with **↑ / ↓**
* Remove unused strategies with **✕**
* Add strategies for new sources (e.g. `from: payload:properties.account_uuid` to match an arbitrary path inside the event payload)
* Match against a **custom trait value** — useful when your customer's account id is stored as a Vitally trait or a Zudo user-defined trait

Strategies are tried in the order shown. Put the most-specific match first so unique IDs win over fuzzy email matching.

## Make sure events have group context

Zudo's account-resolution logic expects most events to carry a group identifier. If your PostHog events don't yet have group context, add it on the client:

```js theme={null}
// Once a user is logged in and you know which account they belong to:
posthog.group("organization", "<your stable account id>", {
	name: "Acme Corp",
});
```

After this call, every subsequent event from that user automatically includes `$groups.organization = "<your stable account id>"`, which Zudo uses for resolution.

## Volume and cost controls

The **rate limit** field on the connection card caps the number of events per minute Zudo will accept from your org. Default is 600/min (\~864K events/day). Events over the cap return `429` to PostHog. Raise or lower this based on your contract.

The **event allowlist** is the biggest cost lever — only allowlisted events enter Postgres. Everything is archived to S3 regardless.

## Troubleshooting

<Steps>
  <Step title="Events not arriving">
    In PostHog, open the destination's logs. If requests are being sent but receive `401`, the bearer token in PostHog
    doesn't match what Zudo has on file — check the `Authorization: Bearer <token>` header (the word `Bearer` followed by
    a space is required). If it's wrong, delete the Zudo connection and recreate to get a fresh token.
  </Step>

  <Step title="“Could not execute bytecode for input field: body.timestamp”">
    PostHog Hog Functions raise this when the body template can't evaluate one of its field expressions — most commonly
    `body.timestamp` because `event.timestamp` is a DateTime, not a string, and the default field-by-field template
    doesn't coerce it.

    **Fix**: change the destination's **Body** to a single Hog expression: `event`. This sends the entire event object
    (no per-field templating), which is what Zudo normalizes from anyway.

    If you need to keep field-by-field templating for other reasons, wrap the timestamp in `toString(...)`:

    ```hog theme={null}
    {
    	"event": event.event,
    	"distinct_id": event.distinct_id,
    	"timestamp": toString(event.timestamp),
    	"properties": event.properties
    }
    ```
  </Step>

  <Step title="Events arriving but no rollups">
    Check the connection's **Recent events** debug list. If `inAllowlist` is `false` for your test events, add them to the
    allowlist. Rollups appear after the next aggregation run (every 10 minutes).
  </Step>

  <Step title="Events showing on the wrong account or no account">
    Identity resolution didn't match. Open the recent events list — the `externalGroupId` and `externalUserId` fields show
    exactly what PostHog sent. The most common cause is a missing group call: confirm that `posthog.group(...)` was
    called with the group type that matches your **Group key** setting, *and* that the group identifier matches one of
    your accounts' `externalId`.
  </Step>

  <Step title="Group key not found">
    If `externalGroupId` is empty in **Recent events** but `distinct_id` is populated, your events don't include the
    expected `$groups.<key>` property. Either fix this on the client with `posthog.group(...)`, or change Zudo's **Group
    key** to whichever key your events actually carry.
  </Step>

  <Step title="Rate-limit (429) responses">
    PostHog retries failed deliveries. If you see sustained 429s, raise the **Rate limit** on the connection or tighten
    the allowlist so fewer events count toward the cap.
  </Step>
</Steps>
