Skip to main content
The PostHog integration ingests product-usage events into Zudo as a webhook destination. Events flow into account timelines, daily counts charts, Indicators, and (optionally) account health scores — without writing custom code.
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.
Zudo also has a separate per-user PostHog integration under Settings → Integrations that pulls person and group properties via HogQL queries. 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.

Before you start

You’ll need:
  • An owner role in your Zudo organization
  • A PostHog project actively capturing events
  • A PostHog group type like organization or company that maps to one of your customer accounts. Zudo resolves events to accounts via properties.$groups[<groupKey>] — without group context, Zudo can only fall back to email-based 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 the allowlist becomes a checkbox-list instead of a textarea. You can add the key 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 HTTP Webhook destination in PostHog that posts each event to that URL with Authorization: Bearer <token>.
  3. PostHog delivers events as they happen.
  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.
The webhook hot path is intentionally fast (under ~80ms p50). Heavy work happens in the background. Both PostHog (egress cost per event forwarded) and Zudo (rollup table size) get cheaper and cleaner if you filter at the PostHog destination level rather than streaming every event and dropping them on the receiving side. The key insight:
  • Every PostHog event carries distinct_id (the user identifier) and, after posthog.group(...) has been called once per session, properties.$groups.<groupKey> (the account identifier). You don’t need to forward $identify or $groupidentify specifically — any normal track event has both.
  • $identify and $groupidentify fire much more often than meaningful product events. Forwarding them is mostly noise paid for at full price.
So the recommendation is to only forward a small, hand-picked set of meaningful events — typically 5–15 — and skip the system events entirely. A real example from Zudo’s own dogfooded setup looks like this:
That list is configured as the PostHog destination’s event filter (so PostHog doesn’t even attempt to send anything else), AND as the Zudo connection’s allowlist (so anything that does sneak through still gets dropped at ingest). One precondition: your PostHog client must call posthog.group("organization", "<account_id>", { name: "..." }) at signup or login. Until that call runs, normal events won’t carry $groups and Zudo can’t resolve them to an account. After it runs, every subsequent event in that session is automatically bound.

Connect

1

Bind users to accounts in your PostHog client

Zudo’s account resolution expects most events to carry a group identifier. If your PostHog events don’t yet have group context, add a posthog.group(...) call wherever a user is associated with an account:
After this call, every subsequent event from that user automatically includes $groups.organization = "<your stable account id>", which Zudo uses for resolution.
Without group context, Zudo falls back to email-based contact matching. That works but is less precise.
2

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 with read access. These let Zudo discover event names directly from your project so the allowlist becomes a checkbox-list. You can also leave them empty and add them later from the connection card.Click Create Connection — Zudo generates the webhook URL and bearer token automatically.
3

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, expand the connection under Settings → Connections and click Rotate to issue a new one. Rotating keeps every other setting and all event history.
4

Add the HTTP Webhook destination in PostHog

PostHog Cloud uses Hog Functions for outbound webhooks. (Self-hosted or older versions: see the Tip at the end of this step.)
  1. Open Data pipeline → Destinations in your PostHog project.
  2. Click New destination and pick HTTP Webhook.
  3. URL: paste the URL Zudo gave you.
  4. Method: POST.
  5. Headers: add two custom headers
    • AuthorizationBearer <your token> (the word Bearer, a single space, then the token)
    • Content-Typeapplication/json
  6. JSON Body: paste this template. Each {...} is a Hog expression PostHog evaluates per event.
    This template puts the resolved person and groups.organization objects in the body alongside the raw event.properties. That gives Zudo three things to work with:
    • properties.$groups.organization — used by the identity chain to look up the account.
    • person.properties.email / person.properties.name — used by the contact chain and (optionally) for naming auto-created contacts.
    • groups.properties.<...> — used (optionally) for naming auto-created accounts. The exact path depends on what you passed to posthog.group(...).
    If your group type isn’t organization, swap groups.organization for groups.<your-key> in both places.
    {event.properties}, {person}, and {groups.organization} are wrapped in quotes — Hog Functions interpolates them as JSON-encoded strings inside the body. Zudo’s normalizer parses them back into objects on receipt, so $groups, email, and other nested fields are accessible. The event. prefix is required on the simple fields because event.event, event.distinct_id, etc. are how the Hog runtime exposes them; a bare {distinct_id} will fail with “Could not execute bytecode for input field”.
  7. Filters → Match events and actions: this is the cost lever. Add an entry for each event name you actually want to forward — typically 5–15 of your most meaningful product events. Do not include $identify or $groupidentify — they fire on every session and inflate your egress without adding signal that the regular events don’t already carry (see Recommended setup). PostHog only sends events that match one of these filters. Everything else is dropped at the source.
  8. Save and enable the destination. PostHog should immediately show a green “active” pill and start delivering events.
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, copy the JSON Body, and enable the plugin. The match-events filter on classic webhook plugins is usually under Configuration → Event filter.
5

Configure the allowlist back in Zudo

Send at least one test event from your app. Then go to Settings → Connections and expand the new PostHog connection. Under Event allowlist there are two paths:
  • With the PostHog query API configured: Zudo shows a checkbox-list of the top 200 distinct event names from your project (last 30 days, ordered by frequency, with counts). Tick the events you want rolled up. Use Add a custom event name… for events that haven’t fired in the last 30 days.
  • Without the API configured: a free-text textarea (one event name per line). You can also click Discover events… to pick from a list of events Zudo has actually received on this connection — works without a PostHog API key.
Either way, click Save allowlist when done — you’ll see a toast confirming the save. Anything outside the allowlist is still archived to S3 (replayable later) but won’t appear in account rollups, indicators, or health scores. The allowlist is the biggest cost lever — keep it tight.
6

Set the group key

Under Identity rules, the Group key field tells Zudo which entry in properties.$groups to read for the account identifier. Defaults to organization (matches the example in step 1). Change only if your posthog.group(...) call uses a different key (e.g. company).
7

Confirm identity rules

The default identity chain works for most setups:
  • Account chain: match $groups[<groupKey>] against Account.externalId, then psId, then vitallyId.
  • Contact chain: match distinct_id against Contact.productExternalUserId, then email against Contact.email.
Customize only if your account/contact identifiers live somewhere non-standard — see Identity rules below.
8

Stamp Account.externalId on at least one Zudo account

For events to appear on a specific Zudo account, that account’s externalId must equal the value you’re passing as the second argument to posthog.group(...). Set it manually on the account, or in bulk via the API.
9

Verify

Trigger a real event from your app. Within a few seconds:
  • The event appears in Recent events at the bottom of the Zudo connection card with Allowlist: yes (if the event name is allowlisted) and the resolved Group matching your account’s externalId.
  • Within ~10 minutes (or after running the aggregation job), the event shows up under the Events filter on the matching Zudo account’s timeline, and the daily-counts chart starts drawing.

What flows in

PostHog Hog Functions deliver one event per HTTP request. Zudo extracts:

A note on $identify and $groupidentify

You don’t need to forward these — see Recommended setup. Every normal track event already carries distinct_id (the user) and properties.$groups.<groupKey> (the account), so Zudo can resolve identity and trigger auto-create from any event. If you do choose to forward them anyway, Zudo handles them correctly:
  • $identifydistinct_id becomes the contact’s external user id; email is read from properties.$set.email (Hog Functions / newer destinations) or $set.email (classic webhook plugin), with a fallback to person.properties.email.
  • $groupidentify — instead of properties.$groups[<groupKey>], these events carry properties.$group_type + properties.$group_key. Zudo reads $group_key as the account identifier only when $group_type matches your configured group key, so a $groupidentify for an unrelated group type doesn’t mis-route.
If you forward them and want them ingested but not shown to CSMs, add the patterns under Hide events from account timelines.

Identity rules

When an 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:
  • Account chain:
    1. Match $groups[<groupKey>] against Account.externalId (the canonical external identifier you stamp on accounts in Zudo)
    2. Match $groups[<groupKey>] against Account.psId (legacy platform/source id)
    3. Match $groups[<groupKey>] against Account.vitallyId (if you also have Vitally connected)
  • 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 populate the right externalId, click Reconcile past events on the connection card to back-fill historic rollups retroactively.

Customize the chain

In the connection’s Identity rules section you can:
  • Reorder strategies with ↑ / ↓
  • Remove strategies you don’t use with
  • Add strategies sourced from arbitrary positions in the event payload (e.g. from: payload:properties.account_uuid to match an arbitrary path)
  • Match against a custom trait value — useful when your 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.

Auto-creating accounts and contacts

By default, Zudo only attaches inbound events to accounts and contacts that already exist. If you’d like new groups and users seen in PostHog events to become accounts and contacts in Zudo automatically, enable auto-creation on the connection card. Everything below lives in Settings → Connections → PostHog. The full set of auto-create controls lives in three collapsible sections on the connection card:
  1. Auto-create from events — the toggles and naming defaults.
  2. Filter which events qualify — predicate rules that gate which events trigger creation.
  3. Hide events from account timelines — events to drop from the account-view UI.

Auto-create toggles

In Auto-create from events:
  • Auto-create Contacts — fires when an event has an email or user id that doesn’t match any existing contact. Optional Contact name from payload path (e.g. person.properties.name); falls back to the email’s local part.
  • Auto-create Accounts — fires when an event has a group id that doesn’t match any existing account. Optional Minimum events before creating threshold (default 1; 5–10 is a common spam guard). Optional Account name from payload path (e.g. groups.properties.name); falls back to Account <id>.
Auto-created records carry source: "product_event" so you can filter, audit, or bulk-delete them by source if you over-create by accident.

Filter which events qualify

Under Filter which events qualify, add predicate rules that gate auto-create independently of the toggles. Two predicate sets per record type — both must pass before a record is created.
  • Payload conditions — match against fields in the PostHog event JSON. Useful paths include properties.$current_url, properties.$lib, properties.environment, anything you’ve set with $set or $group_set, or fields from the resolved person/groups objects like person.properties.email.
  • Identity conditions — match against the resolved identifiers (email, userId from distinct_id, groupId from $groups) plus any payload.* path.
Rules within a set use AND. Empty sets default to allow. Common patterns for PostHog setups:
  • Don’t auto-create from internal users: Payload rule person.properties.email neq mike@yourcompany.com. Add one rule per teammate whose own activity shouldn’t spawn account records.
  • Skip non-production events: Payload rule properties.environment eq production (or whatever field your client sets).
  • Skip free-trial groups from creating accounts: Payload rule properties.$group_set.plan neq trial.
  • Only auto-create when a group really exists: Identity rule groupId is_set — useful belt-and-suspenders if some events ship without group context.
Operators available: equals, not equals, contains, starts with, ends with, , >, , <, is set, is not set, is true, is false.

Discovering payload paths

Each auto-create rule and naming-path field asks for a dot-path into the event JSON, but the exact shape depends on your Hog Function template. The Browse recent payloads panel inside the auto-create section saves you from guessing:
  1. Click Browse recent payloads → Show.
  2. Zudo shows the 5 most recent events from your connection, deduplicated by event name. Each event lists every available dot-path with its sample value.
  3. Click a rule or name field to focus it, then click any path in the browser — it gets inserted into the focused input. With no input focused, the click copies the path to your clipboard.
For the template recommended above, useful paths typically land at person.properties.email, person.properties.name, groups.properties.name, groups.key, properties.$current_url, properties.$lib. The browser shows you exactly what’s in your data.

How creation works

The aggregation job runs every 10 minutes. For each unresolved event, it:
  1. Checks the connection’s auto-create toggles. Skipped if off.
  2. Evaluates the payload + identity rules. Both must pass.
  3. For accounts: confirms the minimum events threshold has been met for the group’s groupId.
  4. Creates the record with source: "product_event" and the resolved externalId (accounts) or email / productExternalUserId (contacts).
  5. Subsequent events for the same group/user resolve normally without re-creating.

What auto-creation does NOT do

  • It doesn’t merge existing duplicate accounts/contacts.
  • It doesn’t enrich created records with data from third-party services.
  • It doesn’t backfill historic events that were already aggregated as accountId: null. To attach those to a newly-created account, use Reconcile past events (below).

Hiding events from account timelines

If a few PostHog events do sneak through that you don’t want shown to CSMs — system events like $identify, $groupidentify, $pageleave, or custom internal-telemetry events — you can hide them from the account view without dropping them from the pipeline. On the connection card, expand Hide events from account timelines and list the patterns to hide. Matching events still flow through the pipeline (ingest, archive, identity resolution, auto-create, rollup) — they just don’t show up in the account Events tab or the daily-counts chart. Supports exact match and * wildcards. A starter set most PostHog customers use if they DO end up forwarding system events:
Or, the nuclear option to hide every PostHog system event (anything starting with $):
That single $* pattern is what we use in the dogfooded Zudo setup — alongside a tight PostHog-side event filter, it means CSMs only ever see meaningful product events on the timeline.

Reconciling historic events

If you stamped Account.externalId on accounts after events had already arrived, those events landed in rollups with accountId: null. To stitch them retroactively:
  1. Go to Settings → Connections and expand the PostHog connection.
  2. Click Reconcile past events at the bottom-left of the settings panel.
  3. Zudo runs an UPDATE that links every null-account rollup whose $groups[<groupKey>] value now matches an Account.externalId. A toast shows the count of rows reconciled.
Safe to run multiple times — only null-account rows are touched.

Volume and cost controls

The rate limit field on the connection card caps the number of HTTP deliveries per minute Zudo will accept from your org. Default is 600/min (~864K deliveries/day). Over-limit deliveries return 429; PostHog retries with backoff. The event allowlist is the biggest cost lever — only allowlisted event names enter Postgres. Everything else is archived to S3 but never queried by accounts/indicators/health scores.

Troubleshooting

1

“Could not execute bytecode for input field: body.<field>”

PostHog Hog Functions raises this when the body template references a Hog expression that doesn’t resolve. The most common cause is missing the event. prefix — event.event, event.distinct_id, event.timestamp, event.properties are how the runtime exposes them. Bare {distinct_id} or {timestamp} fails to compile.Fix: use the JSON Body shown in the connect step above. Every field reference is prefixed with event. and event.properties / person are wrapped in quotes so they JSON-encode safely.
2

“Destination was skipped because the event did not match the filter criteria”

Your destination has a Filter configured that’s excluding the event you’re testing. Either remove the filter, or test with an event that matches it. Filters in PostHog gate delivery; Zudo’s allowlist gates rollup.
3

Events not arriving (401 Unauthorized)

The bearer token in PostHog doesn’t match what Zudo has on file. Check the Authorization: Bearer <token> header — the word Bearer, a single space, then the token. If the token is wrong, delete the Zudo connection and recreate to get a fresh one.
4

Events arriving but no rollups

Check Recent events on the Zudo connection card. If Allowlist: no for your test events, add them via the checkbox-list (or Discover events…). Rollups appear after the next aggregation run, every 10 minutes.
5

Events showing on the wrong account or no account

Identity resolution didn’t match. In Recent events, the Group column shows the value PostHog sent. If it ‘s empty, your events don’t carry $groups.<key> — fix the posthog.group(...) call on the client. If it’s populated but doesn’t match any account, stamp the right externalId on the Zudo account, then click Reconcile past events.
6

Sustained 429 responses

Raise the Rate limit on the connection or tighten the allowlist. PostHog retries 429s with exponential backoff so individual events aren’t lost in spiky traffic.