Guide to PostHog Webhooks: Features and Best Practices
PostHog webhooks let you act on your own product analytics as they happen: send a signup to your CRM, alert a channel when a customer hits a key page, or start a workflow when a custom event fires. If you use PostHog, the open-source product analytics platform, the HTTP Webhook destination in its data pipelines is how those events reach your own services.
This guide covers how the HTTP Webhook destination is configured, how its optional Standard Webhooks signing works and which secret format to use, what the default payload looks like, and the practices that keep a PostHog receiver reliable.
What are PostHog webhooks?
PostHog's webhooks come from a destination in its CDP. You create an HTTP Webhook destination, give it a URL, and set filters that decide which events it handles. For each matching event, PostHog renders the destination's JSON body template and sends it to your URL.
That makes PostHog different from most webhook senders. There is no fixed list of PostHog webhook events and no fixed payload. The events are whatever your product captures, such as $pageview, $identify or signed_up, and the body is whatever your template says.
Signing is optional. If you set the destination's Signing secret, PostHog signs every request following the Standard Webhooks specification, with webhook-id, webhook-timestamp and webhook-signature headers. PostHog's documentation page does not mention signing; the behaviour described here comes from the destination's open-source code.
PostHog webhook features
| Feature | Details |
|---|---|
| Configuration | Data pipelines > + New > Destination, then search for "Webhook" |
| Events | Any event your project captures, chosen with the destination's filters and match rules |
| Body | A JSON template. The default is { "event": "{event}", "person": "{person}" } |
| Method | POST by default. PUT, PATCH, GET and DELETE are also offered |
| Headers | Editable. Content-Type: application/json by default |
| Secret headers | Encrypted static headers, sent verbatim and kept out of the logs |
| Signing | Optional Standard Webhooks signature when a Signing secret is set |
| Signature header | webhook-signature: v1,<base64> |
| Signed content | <webhook-id>.<webhook-timestamp>.<raw body> |
| Secret | Chosen by you. whsec_ plus base64, decoding to at least 24 bytes |
| Delivery ID | webhook-id, constant across retries |
| Testing | Start testing > Test function sends an example event. Log responses helps debugging |
| Source IPs | EU 3.75.65.221, 18.197.246.42, 3.120.223.253; US 44.205.89.55, 52.4.194.122, 44.208.188.173 |
PostHog's docs do not describe a retry policy or a response timeout for the destination. Its source code shows that each attempt is signed with a fresh timestamp while webhook-id stays the same, and that any response of 400 or above counts as a failure.
Common events
The events that reach you are your project's own. A few of PostHog's built-in events show up often:
| Event | Fires when |
|---|---|
$pageview | A page is viewed |
$pageleave | A user leaves a page |
$identify | A user is identified |
$autocapture | Autocapture records a click, input or similar interaction |
Custom names, such as signed_up | Your code captures them |
With the default template, the body has an event object and a person object. PostHog's template globals define their fields: event carries uuid, event (the name), distinct_id, properties, timestamp and a url to view it in PostHog, and person carries id, name, url and properties. The rendered default body looks like this, with placeholder values:
{
"event": {
"uuid": "<event uuid>",
"event": "$pageview",
"distinct_id": "<distinct id>",
"properties": { "$current_url": "https://example.com/pricing" },
"timestamp": "<ISO 8601>",
"url": "<link to the event in PostHog>"
},
"person": {
"id": "<person uuid>",
"name": "<display name>",
"url": "<link to the person in PostHog>",
"properties": {}
}
}
Dispatch on event.event with the default template. If you edit the template, for example to send only a few properties or to add groups or project, your receiver must follow the new shape.
Setting up PostHog webhooks
Start by generating a signing secret in Standard Webhooks form, and store it server-side as POSTHOG_WEBHOOK_SECRET:
echo "whsec_$(openssl rand -base64 32)"
Then create the destination:
- In PostHog, open Data pipelines, click + New > Destination, search for "Webhook" and click + Create.
- Enter the Webhook URL.
- Paste the secret, exactly as generated, into Signing secret. Keep the method as
POST. - Set filters for the events you want, and edit the JSON Body if you need a different shape.
- Click Create & Enable, then use Start testing > Test function to send an example event.
- If your endpoint sits behind a firewall, allow the three IP addresses for your PostHog region.
PostHog does not generate the secret; you do. It accepts a secret with or without the whsec_ prefix and base64-decodes the rest. Hookdeck only base64-decodes a secret that starts with whsec_, and uses any other string as raw bytes. So if you receive through Hookdeck, generate a whsec_ secret and paste the same string into the PostHog destination and the Hookdeck PostHog source's Webhook Signing Secret field. A bare base64 secret, without the prefix, fails verification.
Hookdeck accepts each request, then records any request whose signature does not match as failed verification, so it never becomes an event for your service. If you leave the Signing secret empty, PostHog sends no signature headers, so leave verification off on the Hookdeck source too.
Securing PostHog webhooks
To verify a signed request, strip whsec_ from your secret and base64-decode the rest to get the key. Build <webhook-id>.<webhook-timestamp>.<raw body>, compute an HMAC-SHA256 with that key, base64-encode it, and compare it in constant time with each v1, entry in webhook-signature. The header can hold several space-separated entries.
const crypto = require('crypto');
const express = require('express');
function verifyPostHogSignature(rawBody, headers, secret, toleranceSeconds = 0) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader || !secret) return false;
if (!/^\d+$/.test(timestamp)) return false;
// Optional freshness check, off by default (see below).
if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > toleranceSeconds) {
return false;
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = Buffer.from(
crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest('base64'),
'utf8'
);
return signatureHeader.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/posthog', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.POSTHOG_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyPostHogSignature(req.body, req.headers, secret)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString('utf8'));
// With the default template, route on payload.event.event.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
import time
def verify_posthog_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not msg_id or not timestamp or not signature_header or not secret or not timestamp.isdigit():
return False
# Optional freshness check, off by default.
if tolerance_seconds > 0 and abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
return True
return False
You can also use the standardwebhooks library (new Webhook(secret).verify(rawBody, headers)), which handles the whsec_ prefix for you. That library, like svix, always rejects timestamps more than 5 minutes old. PostHog signs each of its own attempts with a fresh timestamp, so that is fine for direct delivery. Retries and replays from a queue such as Hookdeck keep the original headers, though, so any replay of an event older than 5 minutes fails the library's check. If you receive through Hookdeck, use a manual verifier like the one above with the window off or widened, and deduplicate on webhook-id.
Make PostHog webhooks production-ready. Hookdeck Event Gateway verifies PostHog's Standard Webhooks signature, accepts every event as soon as it arrives, and durably queues it before your service sees it.
PostHog webhook limitations and pain points
The secret format trips up verification
The Problem: Every signed delivery fails verification, even though the same secret is pasted on both sides.
Why It Happens: PostHog base64-decodes the signing secret whether or not it starts with whsec_. Hookdeck, and some other receivers, only decode secrets that start with whsec_ and treat anything else as a plain string. A bare base64 secret therefore produces two different keys. PostHog also refuses to send at all if the secret is not valid base64 or decodes to fewer than 24 bytes.
Workarounds:
- Always generate the secret as
whsec_followed by base64, for examplewhsec_.$(openssl rand -base64 32) - Paste exactly the same string, prefix included, into PostHog and your receiver.
- If PostHog reports a signing error, check that the whole secret was pasted.
How Hookdeck Can Help: Hookdeck's PostHog source verifies the signature with a whsec_ secret, so once the secret is right on both sides, your services never need to hold it.
Signing is documented only in source code
The Problem: It isn't obvious that PostHog can sign requests at all, or how.
Why It Happens: The Signing secret input and its Standard Webhooks behaviour live in PostHog's open-source destination code. The documentation page covers the URL, testing and IP addresses but not signing.
Workarounds:
- Set a Signing secret on every webhook destination you rely on, rather than depending on an unguessable URL.
- If you can't use signing, put a long random token in Secret headers and check it on your receiver.
How Hookdeck Can Help: With Hookdeck in front, a signed destination is verified for you, and the request log shows straight away whether signature headers are arriving.
No fixed payload or event list
The Problem: A template change in PostHog breaks your receiver.
Why It Happens: The body is a user-editable template, and the method and headers can change too. Anyone with access to the destination can change what your endpoint receives.
Workarounds:
- Keep the default template, or version your own template and validate its shape on receipt.
- Log and acknowledge payloads you don't recognise rather than failing them.
How Hookdeck Can Help: Hookdeck stores every request body, so you can see exactly what PostHog sent before and after a template change and replay it against updated code.
Undocumented delivery behaviour
The Problem: You don't know how long PostHog waits, how often it retries, or what happens to events while your endpoint is down.
Why It Happens: PostHog documents no timeout or retry schedule for the destination. Its code treats any status of 400 or above as a failure.
Workarounds:
- Respond quickly with a
2xxand process afterwards. - Turn on Log responses while you set up, to see what PostHog receives back.
- For data you can't afford to miss, reconcile against PostHog's API or exports.
How Hookdeck Can Help: Hookdeck accepts each event when it arrives and stores it, then retries delivery to your service on a schedule you set and lets you replay anything that failed.
Best practices
Verify against the raw body
The signature covers the exact bytes PostHog sent. In Express, mount express.raw() on the route. In Next.js, read await request.text(). In FastAPI, read await request.body().
Deduplicate on webhook-id
PostHog keeps webhook-id the same across retries of one delivery. Store it and skip deliveries you have already processed. The event's own uuid is another good key with the default template. See our guide to webhook idempotency.
Filter in PostHog, not in your receiver
Use the destination's filters and match rules so it only sends the events you act on. High-volume events such as $pageview and $autocapture can produce a lot of traffic if left unfiltered.
Keep the method as POST
The destination offers several HTTP methods. Keep POST, so the body is delivered and signed, and so it matches what your receiver and Hookdeck's PostHog source expect.
Acknowledge fast and process asynchronously
Return 200 once the signature checks out and the event is stored, then do the work. See why to process webhooks asynchronously.
Conclusion
PostHog's HTTP Webhook destination forwards your own events in a body you template, and signs each request with Standard Webhooks headers when you give it a secret. Generate a whsec_ secret and use the same string everywhere, verify against the raw body, keep any timestamp check wide or off behind a queue, deduplicate on webhook-id, and filter at the destination so you only receive what you need.
Hookdeck Event Gateway sits in front of your receiver, checks every PostHog signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle PostHog webhooks reliably in minutes.