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) 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. 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
  • (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

1

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

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

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.
    {
      "event": "{event.event}",
      "distinct_id": "{event.distinct_id}",
      "timestamp": "{event.timestamp}",
      "properties": "{event.properties}",
      "person": "{person}"
    }
    
    {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”.
  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.
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.
4

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

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

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

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.

What flows in

PostHog sends one event per webhook delivery. Zudo extracts:
PostHog fieldUsed for
eventThe event name. Compared against your allowlist.
distinct_idThe user identifier (externalUserId in Zudo).
properties.$groups[<groupKey>]The account identifier (externalGroupId in Zudo).
properties.email / $set.emailContact email, used for fallback identity resolution.
timestampWhen the event occurred.
Full payloadArchived 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:
// 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

1

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

“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(...):
{
	"event": event.event,
	"distinct_id": event.distinct_id,
	"timestamp": toString(event.timestamp),
	"properties": event.properties
}
3

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

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

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

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.