PostHog-triggered journeys
Forward identified PostHog events into journeys — with the guards that keep echoes and anonymous noise out.
The production PostHog receiver: echo guard, reserved-namespace guard, identified-only guard, and the event/person property split.
Full write-up// Hogsend's own outbound catalog — if the PostHog destination forwards
// broadly, events Hogsend fanned out would echo straight back into ingest.
const RESERVED_EVENT_PREFIXES = ["email.", "journey.", "bucket.", "contact."];
export const posthogSource = defineWebhookSource({
meta: { id: "posthog", name: "PostHog" },
auth: {
type: "match",
header: "x-posthog-webhook-secret",
envKey: "POSTHOG_WEBHOOK_SECRET",
},
schema: posthogWebhookSchema, // zod for PostHog's { event, person } body
async transform(payload) {
const eventName = payload.event.event;
const userId = payload.event.distinct_id;
const rawEmail = payload.person?.properties?.email;
const userEmail = typeof rawEmail === "string" ? rawEmail : "";
// Echo guard: never re-ingest events Hogsend itself fanned out.
if (payload.event.properties?.$lib === "hogsend") return null;
if (RESERVED_EVENT_PREFIXES.some((p) => eventName.startsWith(p))) {
return null;
}
// Identity guard: a person with an email, or an identified session.
// Anonymous distinct_ids would mint a junk contact per session.
if (!userEmail && payload.event.properties?.$is_identified !== true) {
return null;
}
// Property split: behavioral data → eventProperties (trigger.where,
// exitOn); profile data → contactProperties (contact merge). Never mixed.
const eventProperties: Record<string, unknown> = {
...payload.event.properties,
};
if (payload.event.uuid) {
eventProperties._posthogEventId = payload.event.uuid;
}
return {
event: eventName,
userId,
userEmail,
eventProperties,
contactProperties: { ...payload.person?.properties },
};
},
});