Agent skill
PostHog Webhooks Skill
Receive and verify PostHog webhooks from the Webhook destination (CDP). Use when setting up PostHog webhook handlers, debugging Standard Webhooks signature verification (webhook-id, webhook-timestamp, webhook-signature), choosing a signing secret, or handling events like $pageview, $identify, or custom events.
Install this skill
npx skills add hookdeck/webhook-skills --skill posthog-webhooks
When to Use This Skill
- How do I receive PostHog webhooks?
- How do I verify PostHog webhook signatures?
- What secret do I put in the PostHog Webhook destination "Signing secret" input?
- How do I handle
$pageview,$identify, or custom events from PostHog? - Why does PostHog signature verification fail behind Hookdeck or after a retry?
How PostHog Signs Webhooks
The CDP Webhook destination has an optional Signing secret. When set, each request follows Standard Webhooks:
| Header | Value |
|---|---|
webhook-id | Unique message ID (constant across retries of one message) |
webhook-timestamp | Unix seconds when that attempt was signed |
webhook-signature | v1,<base64> |
Signature: HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>, base64 encoded. Signing is documented only in PostHog's open-source code (standard-webhooks.ts), not on the docs page. No signing secret means no signature headers.
You choose the secret
PostHog does not issue one. It strips an optional whsec_ prefix, base64-decodes the rest, and requires at least 24 decoded bytes. Generate a whsec_<base64> secret and paste the same string into PostHog and your receiver (or Hookdeck):
echo "whsec_$(openssl rand -base64 32)"
A bare base64 secret works in PostHog but fails Hookdeck, so always keep the whsec_ prefix.
Verification (core)
const { Webhook } = require('standardwebhooks');
// rawBody MUST be the unparsed body (Express: express.raw({ type: '*/*' }))
const payload = new Webhook(process.env.POSTHOG_WEBHOOK_SECRET).verify(rawBody, {
'webhook-id': req.headers['webhook-id'],
'webhook-timestamp': req.headers['webhook-timestamp'],
'webhook-signature': req.headers['webhook-signature'],
}); // throws on a bad signature or a timestamp older than 5 minutes
Python: from standardwebhooks.webhooks import Webhook, then Webhook(secret).verify(raw_.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Timestamp window and Hookdeck retries
The standardwebhooks and svix libraries always enforce a fixed 5-minute timestamp window. PostHog signs a fresh timestamp on each of its own attempts, but Hookdeck retries and replays resend the original timestamp, so the library rejects them once they are more than 5 minutes old. If you receive through Hookdeck and need late retries or replays to pass, use the manual verifier in references/verification.md, where the window can be dropped or widened (deduplicate on webhook-id instead).
Event Payload
The body is whatever the destination's template sends. The default is { "event": {...}, "person": {...} }. Dispatch on event.event:
event.event | Meaning |
|---|---|
$pageview | Page viewed |
$identify | User identified |
$autocapture | Autocaptured click/input |
<custom name> | Any custom event, e.g. signed_up |
Environment Variables
POSTHOG_WEBHOOK_SECRET=whsec_your_base64_secret_here
Local Development
npx hookdeck-cli listen 3000 posthog --path /webhooks/posthog
No account required. Use the printed Hookdeck URL as the PostHog destination URL. Use port 8000 for FastAPI.
Reference Materials
- references/overview.md - What PostHog webhooks are, payload and events
- references/setup.md - Create the destination, generate the secret
- references/verification.md - Signature details, manual verifier, gotchas