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
organizationorcompanythat 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
- Zudo gives you a unique webhook URL and a bearer token.
- You add a webhook destination in PostHog that posts each event to the URL with
Authorization: Bearer <token>. - PostHog delivers events in near real time.
- Zudo verifies the bearer token, archives the raw event to S3, and inserts a row into the staging table.
- Every 10 minutes a job aggregates allowlisted events into per-account daily rollups and resolves which Zudo account each event belongs to.
Connect
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.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.
Add the webhook destination in PostHog
The exact path depends on your PostHog version. PostHog Cloud (current) uses Hog Functions for outbound webhooks:
- Open Data pipeline → Destinations in your PostHog project.
- Click New destination and search for HTTP Webhook (or just Webhook).
- URL: paste the URL Zudo gave you.
-
Method:
POST. -
Headers: add a custom header
- Name:
Authorization - Value:
Bearer <your token>(paste the token Zudo gave you, prefixed withBearerand a space) - Add another:
- Name:
Content-Type - Value:
application/json
- Name:
-
JSON Body: paste this exactly. Each
{...}is a Hog expression that PostHog evaluates against the event being delivered. Zudo readsevent,distinct_id,timestamp, andproperties(for$groupsandemail) out of the payload.{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. Theevent.prefix is required on most fields becauseevent.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”. - 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.
- Save and enable the destination.
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.
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.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.
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:
- Match
$groups[<groupKey>]againstAccount.externalId(the canonical external identifier you’ve stamped on the account in Zudo) - Match
$groups[<groupKey>]againstAccount.psId(your existing internal account id) - Match
$groups[<groupKey>]againstAccount.vitallyId(if you’ve also got Vitally synced)
- Match
- Contact chain (used when no account match was found):
- Match
distinct_idagainstContact.productExternalUserId - Match
emailagainstContact.email
- Match
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_uuidto 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
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:$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 return429 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
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.“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(...):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).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.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.