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:

HeaderValue
webhook-idUnique message ID (constant across retries of one message)
webhook-timestampUnix seconds when that attempt was signed
webhook-signaturev1,<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_body, headers).

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.eventMeaning
$pageviewPage viewed
$identifyUser identified
$autocaptureAutocaptured click/input
<custom name>Any custom event, e.g. signed_up

See references/overview.md.

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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 5, 2026

View on GitHub →