Before you start
You’ll need:- An owner role in your Zudo organization
- A PostHog project actively capturing events
- A PostHog group type like
organizationorcompanythat maps to one of your customer accounts. Zudo resolves events to accounts viaproperties.$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
- Zudo gives you a unique webhook URL and a bearer token.
- You add a HTTP Webhook destination in PostHog that posts each event to that URL with
Authorization: Bearer <token>. - PostHog delivers events as they happen.
- 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.
Recommended setup: forward only the events you care about
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, afterposthog.group(...)has been called once per session,properties.$groups.<groupKey>(the account identifier). You don’t need to forward$identifyor$groupidentifyspecifically — any normal track event has both. $identifyand$groupidentifyfire much more often than meaningful product events. Forwarding them is mostly noise paid for at full price.
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
Bind users to accounts in your PostHog client
posthog.group(...) call wherever a user is associated with an account:$groups.organization = "<your stable account id>", which Zudo uses for resolution.Create the connection in Zudo
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.Copy the webhook URL and bearer token
Add the HTTP Webhook destination in PostHog
- Open Data pipeline → Destinations in your PostHog project.
- Click New destination and pick HTTP Webhook.
- URL: paste the URL Zudo gave you.
-
Method:
POST. -
Headers: add two custom headers
Authorization→Bearer <your token>(the wordBearer, a single space, then the token)Content-Type→application/json
-
JSON Body: paste this template. Each
{...}is a Hog expression PostHog evaluates per event.This template puts the resolvedpersonandgroups.organizationobjects in the body alongside the rawevent.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 toposthog.group(...).
organization, swapgroups.organizationforgroups.<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. Theevent.prefix is required on the simple fields becauseevent.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”. -
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
$identifyor$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. - Save and enable the destination. PostHog should immediately show a green “active” pill and start delivering events.
Configure the allowlist back in Zudo
- 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.
Set the group key
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).Confirm identity rules
- Account chain: match
$groups[<groupKey>]againstAccount.externalId, thenpsId, thenvitallyId. - Contact chain: match
distinct_idagainstContact.productExternalUserId, thenemailagainstContact.email.
Stamp Account.externalId on at least one Zudo account
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.Verify
- 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 resolvedGroupmatching 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:
$identify—distinct_idbecomes the contact’s external user id; email is read fromproperties.$set.email(Hog Functions / newer destinations) or$set.email(classic webhook plugin), with a fallback toperson.properties.email.$groupidentify— instead ofproperties.$groups[<groupKey>], these events carryproperties.$group_type+properties.$group_key. Zudo reads$group_keyas the account identifier only when$group_typematches your configured group key, so a$groupidentifyfor an unrelated group type doesn’t mis-route.
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:
- Match
$groups[<groupKey>]againstAccount.externalId(the canonical external identifier you stamp on accounts in Zudo) - Match
$groups[<groupKey>]againstAccount.psId(legacy platform/source id) - Match
$groups[<groupKey>]againstAccount.vitallyId(if you also have Vitally connected)
- Match
- Contact chain (used when no account match was found):
- Match
distinct_idagainstContact.productExternalUserId - Match
emailagainstContact.email
- Match
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_uuidto 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
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:- Auto-create from events — the toggles and naming defaults.
- Filter which events qualify — predicate rules that gate which events trigger creation.
- 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 toAccount <id>.
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$setor$group_set, or fields from the resolvedperson/groupsobjects likeperson.properties.email. - Identity conditions — match against the resolved identifiers (
email,userIdfromdistinct_id,groupIdfrom$groups) plus anypayload.*path.
- 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.
≥, >, ≤, <, 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:- Click Browse recent payloads → Show.
- 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.
- 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.
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:- Checks the connection’s auto-create toggles. Skipped if off.
- Evaluates the payload + identity rules. Both must pass.
- For accounts: confirms the minimum events threshold has been met for the group’s
groupId. - Creates the record with
source: "product_event"and the resolvedexternalId(accounts) oremail/productExternalUserId(contacts). - 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:
$):
$* 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 stampedAccount.externalId on accounts after events had already arrived, those events landed in rollups with accountId: null. To stitch them retroactively:
- Go to Settings → Connections and expand the PostHog connection.
- Click Reconcile past events at the bottom-left of the settings panel.
- Zudo runs an UPDATE that links every null-account rollup whose
$groups[<groupKey>]value now matches anAccount.externalId. A toast shows the count of rows reconciled.
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 return429; 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
“Could not execute bytecode for input field: body.<field>”
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.“Destination was skipped because the event did not match the filter criteria”
Events not arriving (401 Unauthorized)
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.Events arriving but no rollups
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.Events showing on the wrong account or no account
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.Sustained 429 responses
