PostHog Webhook Setup
Forward the right PostHog events to your running app via PostHog's Destinations pipeline — into POST /v1/webhooks/posthog, where they trigger journeys. With screenshots.
Before you start
This connects your PostHog instance to a running Hogsend api. Make sure you've finished Installation and that GET /v1/health returns healthy. PostHog forwards events to your app's POST /v1/webhooks/posthog endpoint, which feeds the same ingestion pipeline that triggers journeys.
PostHog's servers must be able to reach your app over the internet. A locally-running app on http://localhost:3002 is not reachable by PostHog Cloud — deploy first, or use a tunnel (ngrok, Cloudflare Tunnel) to expose your local api while testing.
Engine vs. your content
The PostHog receiver is your content, not the engine. The scaffold ships a webhook source at src/webhook-sources/posthog.ts defined with defineWebhookSource(), registered in src/webhook-sources/index.ts, and wired into createApp(client, { webhookSources }). The engine serves whatever sources you pass at POST /v1/webhooks/:sourceId — here the source id is posthog, so the route is POST /v1/webhooks/posthog. You own that file and can change how PostHog payloads map into events at any time.
Two directions, two secrets
PostHog and Hogsend talk to each other in both directions, and each direction uses its own credential. This page is about the inbound direction; don't conflate it with outbound.
| Direction | Flow | Credential | What it does |
|---|---|---|---|
| Inbound | PostHog → Hogsend | POSTHOG_WEBHOOK_SECRET | Product events enter the engine via POST /v1/webhooks/posthog and trigger journeys. This is what this page sets up. |
| Outbound | Hogsend → PostHog | POSTHOG_API_KEY (+ the posthog destination) | The engine's lifecycle event stream — including email open and link click events it generates — flows back into your PostHog project. |
Email open/click tracking does NOT automatically show up in PostHog. Hogsend always records opens and clicks in its own database, but it only mirrors them into PostHog when you point a posthog outbound destination at your project (or set ENABLE_POSTHOG_DESTINATION=true with POSTHOG_API_KEY). That is separate from POSTHOG_WEBHOOK_SECRET (inbound) — setting the webhook secret on this page gets events into Hogsend, but does nothing for getting Hogsend's engagement events into PostHog.
Overview
This guide connects your PostHog instance to Hogsend using PostHog's Destinations pipeline. Once set up, the PostHog events you select automatically flow into Hogsend and trigger your lifecycle journeys.
There are two approaches:
- HTTP Webhook Destination (recommended) — send specific events from PostHog to your app's webhook endpoint. You pick exactly which events to forward.
- PostHog Workflows — a newer automation builder that gives you more filtering logic before events are sent. More powerful but more complex to set up.
For most teams, Option 1 is the right starting point. You can always add Workflows later.
Don't forward everything. PostHog captures a lot of events — $pageview, $autocapture, $feature_flag_called, etc. You only want to send the 5–15 events that your journeys actually care about.
Option 1: HTTP Webhook Destination
This is the fastest path. You create a webhook destination in PostHog that sends selected events to your app's POST /v1/webhooks/posthog endpoint.
Here's the full setup flow:

Step 1: Open Data > Destinations
In your PostHog dashboard, click Data in the left sidebar, then select Destinations under the Pipeline section. This is where PostHog manages all outbound data integrations.
Step 2: Search for "HTTP Webhook"
In the "Create a new destination" section at the bottom, use the search box to find HTTP Webhook. Click + Create to open the configuration form.
Step 3: Configure the webhook
You'll see a two-column form. Here's what to set:
Right column — Webhook settings:
| Field | Value |
|---|---|
| Webhook URL | https://your-hogsend-api.com/v1/webhooks/posthog |
| Method | POST (default) |
| JSON Body | Leave the default — it sends {event} and {person}, which is exactly what the PostHog source expects |
Include person properties. Your PostHog source reads the contact's email from person.properties.email (and event.distinct_id for userId). If the destination's JSON body doesn't expand {person} — or the person has no email property — the event still lands, but the contact has no address, so the welcome journey can't email them. Keep {person} in the body and make sure email is set as a person property in PostHog (typically on signup).
Headers — Add the auth secret:
The form already has a Content-Type: application/json header. Click + Add entry to add your webhook secret:
| Header | Value |
|---|---|
x-posthog-webhook-secret | The value you set as POSTHOG_WEBHOOK_SECRET in your Hogsend environment |
Both sides must match. PostHog sends the x-posthog-webhook-secret header; the source compares it against the POSTHOG_WEBHOOK_SECRET env var on your app. If they don't match, the request is rejected with 401 Invalid webhook secret and the event never enters the pipeline.
The posthog source uses auth.type: "match" — plain shared-secret equality. If POSTHOG_WEBHOOK_SECRET is unset, the source stays open and accepts unauthenticated requests. Always set it in any internet-reachable deployment.
Step 4: Add event matchers (critical)
This is the most important step. Do not skip this.
In the left column under "Match events and actions," click + Add event matcher. A dropdown appears where you can search and select specific PostHog events.
Without event matchers, the destination fires on every PostHog event. On a real project, that's thousands of events per day — $pageview, $autocapture, $pageleave, etc. — none of which your journeys need.
With a user_signed_up event matcher selected, the matching-events counter drops from 15,104 triggers (all events) to 17 triggers (just signups) over the same 7-day window. That's the difference between hammering your app with noise and sending exactly what matters.
Add one matcher per event you care about. Click + Add event matcher multiple times to add several.
Step 5: Choose your starter events
These are the events most lifecycle journeys need. Add matchers for whichever ones exist in your PostHog project:
| Event | Journeys it powers |
|---|---|
user_signed_up | Welcome sequence, onboarding nudges |
user_activated | Activation branching (skip nudge if already activated) |
feature_used | Feature adoption flows |
trial_started | Trial-to-paid conversion |
subscription_created | Post-purchase onboarding |
subscription_cancelled | Churn recovery, win-back |
payment_failed | Payment failure recovery |
payment_succeeded | Exit condition for payment recovery journeys |
checkout_abandoned | Abandoned checkout recovery |
You don't need all of these on day one. Start with 3–4 events that match the journeys you're building, and add more as you go.
Your PostHog event names might differ — $signup instead of user_signed_up, for example. Use whatever names exist in your PostHog project. The source forwards the event name as-is, and a journey's trigger.event must match that string exactly.
Step 6: Enable and create
Toggle Filter out internal and test users on if you want to skip events from your team (recommended for production).
Leave Trigger options on "Run every time" unless you have a reason to throttle.
Click Create & enable. The destination is immediately active — the next time one of your matched events fires in PostHog, it'll hit your app.
Step 7: Verify it's working (assert the event landed)
Configuring the destination is not proof — seeing the event in Hogsend is. Fire one of your matched events from your app (or use PostHog's "Start testing" button on the destination page), then assert it landed by querying the admin events endpoint. Filter by the exact event name you just fired so you're confirming that event, not just any recent traffic:
# Assert the specific event arrived. Swap user_signed_up for the event you fired.
curl -H "Authorization: Bearer $ADMIN_API_KEY" \
"https://your-hogsend-api.com/v1/admin/events?event=user_signed_up&limit=5"A successful response looks like this — total is non-zero and the event is in the list:
{
"events": [
{
"id": "…",
"userId": "user_abc123",
"event": "user_signed_up",
"properties": { "plan": "pro", "_posthogEventId": "…" },
"occurredAt": "2025-01-15T10:30:00.000Z"
}
],
"total": 1,
"limit": 5,
"offset": 0
}properties here is the event properties bag (from PostHog's event.properties), with _posthogEventId added by the source. The contact's email lives on the contact record (it came in via person.properties → contact properties), not in this properties bag — see the split below. Check that userId (mapped from distinct_id) is populated; that and a contact with an address are the proof a journey can email the user. You can also narrow to a single contact with &userId=user_abc123.
If total is 0 and the list is empty, the event never landed. Check:
- Is the webhook URL correct? (including the
/v1/webhooks/posthogpath) - Is
POSTHOG_WEBHOOK_SECRETset in your Hogsend environment, and does thex-posthog-webhook-secretheader value match it exactly? A mismatch returns401 Invalid webhook secretand the event is dropped — check PostHog's History tab for401s. - Is the app running and reachable from the internet?
- The endpoint requires admin auth —
$ADMIN_API_KEY(sent asAuthorization: Bearer …) is a different credential fromPOSTHOG_WEBHOOK_SECRET. A401from this curl means your admin key is wrong, not the webhook secret.
Option 2: PostHog Workflows
PostHog Workflows (from the Laudspeaker acquisition) is a newer automation builder. It lets you set up more complex routing logic — filter events by properties, add delays, branch on conditions — before sending to a webhook.
This is useful when:
- You want to filter events by properties before they reach Hogsend (e.g. only send
subscription_createdevents for the "pro" plan) - You want to throttle or deduplicate events at the PostHog level
- You're already using Workflows for other automations and want to keep everything in one place
For most Hogsend setups, the HTTP Webhook Destination (Option 1) is simpler and gives you everything you need. A journey's trigger.where conditions can handle property-level filtering, so you typically don't need PostHog Workflows for that.
If you do want to use Workflows, the setup is similar: create a workflow triggered by specific events, add any filtering conditions you need, and use an HTTP action step to POST to https://your-hogsend-api.com/v1/webhooks/posthog with the same JSON body and headers.
The JSON body PostHog sends
For reference, this is what PostHog's HTTP Webhook destination sends to your app. The default body template uses {event} and {person} placeholders that PostHog expands:
{
"event": {
"uuid": "01234567-89ab-cdef-0123-456789abcdef",
"event": "user_signed_up",
"distinct_id": "user_abc123",
"timestamp": "2025-01-15T10:30:00.000Z",
"properties": {
"plan": "pro",
"source": "landing-page",
"$browser": "Chrome",
"$os": "Mac OS X"
}
},
"person": {
"id": "person-uuid",
"properties": {
"email": "alice@example.com",
"name": "Alice",
"plan": "pro"
}
}
}The scaffold's PostHog source (src/webhook-sources/posthog.ts) transforms this into an IngestEvent, keeping the two property bags distinct — event properties and contact properties are never merged:
| PostHog field | Hogsend field | Where it goes |
|---|---|---|
event.event | event (the event name) | the journey trigger string |
event.distinct_id | userId | identity |
person.properties.email | userEmail | identity (the contact's address) |
event.uuid | eventProperties._posthogEventId | stored on the event |
event.properties | eventProperties | stored on the event; feeds trigger.where / exitOn |
person.properties | contactProperties | merged onto the contact profile |
This is the contactProperties vs eventProperties split that runs through the whole data plane: behavioral event data drives condition evaluation (trigger.where, exitOn), while identity/profile data merges onto the contact record. The two are never combined. See Events & Ingestion.
Troubleshooting
Events aren't arriving in Hogsend
- Check PostHog's History tab — go to Data > Destinations, click the History tab. You should see successful deliveries. If you see errors, the webhook URL or auth is wrong.
- Check your event matchers — if no matchers are added, all events fire. If matchers are added, make sure they match the exact event names in your PostHog project.
- Check networking — your app must be reachable from PostHog's servers. If you're running locally, PostHog can't reach
localhost.
Too many events hitting Hogsend
Add event matchers (Step 4 above). Without them, PostHog sends everything — including $pageview, $autocapture, and other high-volume internal events.
Events arrive but journeys don't trigger
The event name must exactly match the trigger.event in your journey's meta. Event names are case- and punctuation-sensitive — the scaffold's example journeys use dot-notation (user.created), but Hogsend uses whatever string you put in trigger.event, so make sure it matches the PostHog event name exactly (e.g. user_signed_up, not user:signed_up). See Journeys.
Person email is missing
The source reads the email from person.properties.email. If this isn't set in PostHog, Hogsend can track the event but can't send emails. Set email as a person property in PostHog early (typically on signup).