This guide connects Candu to PostHog so that every user event in your Candu content becomes a PostHog event, joined to the same person record as the rest of your product analytics.
No middleware, no Zapier or Make, and no code changes in your app. Candu posts to a PostHog webhook, and a PostHog workflow turns that into a captured event.
What you get
Every user event type flows through a single webhook. Button clicks, form submissions, rich text copies, and anything else Candu tracks:
PostHog field | Example |
Event name | Finish Sign Up Flow Clicked |
Person | 16948 (your Candu userId) |
Current URL | |
candu_topic | candu.sdk.content.interaction |
candu_node_type | Button, Form, RichText |
candu_form_data | {"Main Goal":"Feature adoption"} |
Before you start: check your identity join
This is the one thing that will silently ruin the integration if you get it wrong.
Candu sends its own userId. PostHog identifies people by distinct_id. If those two values are not the same string for the same human, your Candu events will land against a separate, orphaned set of people, and none of it will join to your product analytics. There is no error message when this happens. It simply produces useless data.
Before building anything, confirm that the userId you pass to Candu is the same identifier you pass to posthog.identify(). If they differ, fix that first.
Step 1: Create the PostHog workflow
In PostHog, go to Tools > Workflows and create a new workflow.
Click the Trigger node and set Trigger type to Webhook.
Copy the webhook URL shown under Usage instructions. You will need it in Step 4.
Turn on Log payloads. This is invaluable while you are setting up.
Step 2: Map the Candu fields on the Trigger
Still on the Trigger node, set these three fields.
Event name
{request.body.payload.properties.eventName ?? request.body.payload.properties.name}
Distinct ID
{request.body.payload.userId}
Event properties
{
"$ip": "{request.ip}",
"$lib": "posthog-webhook",
"$current_url": "{request.body.payload.context.page.url}",
"candu_topic": "{request.body.payload.eventName}",
"candu_content_id": "{request.body.payload.properties.contentId}",
"candu_node_type": "{request.body.payload.properties.nodeType}",
"candu_trigger": "{request.body.payload.properties.trigger}",
"candu_placement_id": "{request.body.payload.properties.placementId}",
"candu_segment_id": "{request.body.payload.properties.segmentId}",
"candu_document_id": "{request.body.payload.properties.documentId}",
"candu_form_data": "{request.body.payload.properties.formData}",
"candu_anonymous_id": "{request.body.payload.anonymousId}",
"candu_properties": "{request.body.payload.properties}"
}
Two things to note about these mappings, both explained in Troubleshooting below:
Every path starts with
request.body.payload. Thepayloadlevel is easy to miss.The
??in Event name is doing real work. Different Candu event types store the readable name under different keys.
Step 3: Add the Capture event action
The Trigger alone does not write anything to PostHog. You need an action node.
From the Build panel, drag Capture event (under PostHog actions) onto the line between Trigger and Exit.
Leave its three fields on the defaults:
Event name:
{event.event}Distinct ID:
{event.distinct_id}Event properties:
{event.properties}
Do not use request.* paths in this node. The Trigger has already turned the incoming request into an event; the action node reads that event. Using request.* here fails with Could not execute bytecode for input field: event.
Click Save, then Enable the workflow.
Step 4: Create the Candu webhook
In Candu, go to Settings > Integrations > Webhooks and click Set up a new Webhook.
Give it a name, for example "PostHog".
Paste the PostHog webhook URL into Endpoint URL.
Leave Format as Default (.json).
Expand Content interactions and tick Enable all user events. This single toggle covers button clicks, form submissions, rich text copies and every other user event type.
Click Save.
Step 5: Verify
In your app, click a button or submit a form inside a piece of Candu content.
Back in Candu, open Settings > Integrations > Webhooks and click View on your webhook. Under Recent Webhook Events you should see a teal dot. Click it and you should see
201with{"status":"queued"}.A red dot means the delivery failed. Click it to read the response.
In PostHog, go to Activity. Your event should appear within a minute or two, with the person and URL populated.
Troubleshooting
Every delivery returns 400 "event could not be parsed correctly"
This means your Event name mapping resolved to nothing. Almost always it is a wrong path, and almost always it is one of the two issues below.
The payload wrapper
Candu's actual HTTP body wraps everything in a payload key:
{
"payload": {
"userId": "16948",
"eventName": "candu.sdk.content.interaction",
"properties": { ... },
"context": { "page": { "url": "..." } }
}
}
Candu's own Triggering Event viewer displays the inner object, not the wire body. If you copy paths from that screen you will be exactly one level too shallow, every path will silently resolve to nothing, and every delivery will 400.
The example payload panel on the webhook setup form can also differ from what is actually sent, and differs between topics on the same screen. Trust neither. Use the technique below instead.
How to see the real payload
When a mapping is not resolving and you cannot tell why, capture the raw body:
On the Trigger, temporarily set Event name to a literal string such as
debug_probeand Distinct ID todebug_user, so delivery cannot fail.Set Event properties to
{"raw_body": "{request.body}"}.Save, then resend an event from Candu.
Open the resulting event in PostHog Activity and expand
raw_body. That is the exact wire format.Write your real mappings against it, then remove the debug properties.
Do not leave raw header capture enabled in a live setup, as request headers can contain credentials.
Two fields called eventName
There are two, at different depths, and they mean different things:
Path | Value | Use for |
payload.eventName | candu.sdk.content.interaction | The topic. Map to a property. |
payload.properties.eventName | Finish Sign Up Flow Clicked | The event name you want. |
Form submissions use a different key
Most event types store the readable name in properties.eventName. Form submissions store it in properties.name. Same purpose, different key. That is why the Event name mapping uses ?? to fall back, which lets one webhook and one workflow serve every event type.
The field autocomplete is misleading
In the Capture event node, autocomplete offers request and variables but does not list event. Ignore it. event is available and is what you want. PostHog's own pre-filled defaults are correct.
The Event properties box is strict JSON
Templates must be quoted. "candu_properties": {request.body.payload.properties} fails validation. "candu_properties": "{request.body.payload.properties}" works, and still arrives in PostHog as a real nested object rather than a string.
An alternative worth considering
If you can add a few lines to your app, calling posthog.capture() from Candu's client side callback gives you better data than the webhook route. Identity matches automatically because it is the same PostHog instance in the same browser session, and events link to session replays. The webhook approach documented here is the right choice when you cannot ship code.
Going the other way
PostHog can also send data into Candu: person properties, cohort membership, and activity become Candu traits you can use in segments and targeting. See Send PostHog data to Candu.






