Connect PostHog
hogsend connect posthog — a PKCE OAuth flow with one browser consent click that stores an encrypted credential on your instance and idempotently provisions the PostHog → Hogsend webhook destination.
hogsend connect posthog is the one-command path to a fully wired PostHog integration. It authorizes Hogsend against your PostHog region (one browser consent click), stores the resulting credential encrypted on your instance, and provisions the PostHog → Hogsend webhook destination — the same one the manual walkthrough builds by hand.
hogsend connect posthog --url https://api.your-app.comRun it from your laptop. The OAuth callback lands on 127.0.0.1 on the machine running the CLI, so the browser consent must happen there — the target instance can be anywhere; --url points at it. From an SSH session on the server this cannot complete (there is no paste-the-redirect fallback).
Prerequisites
- A deployed instance.
API_PUBLIC_URLmust be a host PostHog Cloud can reach — provisioning refuses loopback addresses outright, so a localhttp://localhost:3002instance can store the credential but never gets a destination pointed at it. - An admin key (
--admin-keyorHOGSEND_ADMIN_KEY), like every admin CLI command. - A region to authorize against. The CLI needs no PostHog env vars — locally or on the server. Pass
--posthog-host https://eu.posthog.com(orhttps://us.posthog.com, or your self-hosted app URL) to pick the region, or run interactively and choose it from the prompt (EU / US Cloud / custom). If the server already hasPOSTHOG_API_KEY/POSTHOG_HOST, the CLI reads the region from there instead and you can skip the flag.
What it does, step by step
- Asks the instance for its connect info —
GET /v1/admin/analytics/connect-inforeturns the region (when the server has one), readiness flags,API_PUBLIC_URL, and ascopeGap(expected scopes any stored credential is missing). Secrets never appear in the response, only whether they're configured. - Resolves the region — from
--posthog-host, the interactive region prompt, or the server's ownPOSTHOG_HOST/POSTHOG_API_KEYwhen it has one. The CLI needs no PostHog env vars to start. - Discovers the OAuth server —
GET {your-region}/.well-known/oauth-authorization-serveragainst the resolved PostHog host. Nothing is hardcoded, so EU, US, and self-hosted instances all authorize against the right place. A 404 here means the instance doesn't ship OAuth (older self-hosted builds) — the CLI points you at the personal-key fallback instead. - Starts a loopback server and opens your browser. PKCE (S256) with a one-shot state value; the callback lands on
127.0.0.1port 8423, 8424, or 8425 — the three redirect URIs registered in Hogsend's OAuth client document. Hogsend is a public OAuth client: there is no client secret anywhere in the flow. - You click consent in PostHog. Once. The requested scopes are
person:read person:write project:read organization:read hog_function:read hog_function:write feature_flag:read cohort:read cohort:write query:read insight:read event_definition:read property_definition:read, and the flow requests team-level access — the narrowest grant PostHog offers. The set is front-loaded beyond the webhook loop's needs (which is onlyhog_function:writeplus the minted secret) so future read/write features land without forcing a reconnect. - Exchanges the code for tokens — a ~7-day access token and a long-lived refresh token, straight from PostHog to the CLI over the PKCE-bound exchange.
- Stores the credential on the instance —
PUT /v1/admin/provider-credentials/posthog. The payload is encrypted at rest withBETTER_AUTH_SECRET. Nothing is written on your laptop, and no token ever appears in the CLI's output, JSON or human. - Provisions the loop —
POST /v1/admin/analytics/provision-loopruns server-side with the just-stored credential and creates (or adopts) the PostHog destination that POSTs identified events to{API_PUBLIC_URL}/v1/webhooks/posthog. If the server has noPOSTHOG_WEBHOOK_SECRET, it mints a signing secret, persists it encrypted, and stamps it on the destination header — the inbound webhook source resolves that same secret from the store at request time, so the loop verifies without a redeploy. On the way through it also reads the project's public key (the projectapi_token) and stores it for the optional outbound capture path — nophc_is ever pasted by hand.
The whole run looks like this:
┌ hogsend connect
◇ GET https://api.your-app.com/v1/admin/analytics/connect-info ✓
◇ OAuth discovery at https://eu.posthog.com ✓
│
│ About to authorize Hogsend against PostHog
│ instance https://api.your-app.com
│ posthog https://eu.posthog.com
│ scopes person:read person:write project:read organization:read hog_function:read hog_function:write feature_flag:read cohort:read cohort:write query:read insight:read event_definition:read property_definition:read
│ callback http://127.0.0.1:8423/callback
│
◇ Waiting for PostHog authorization (Ctrl-C aborts) ✓
◇ Exchanging code at https://eu.posthog.com/oauth/token/ ✓
◇ PUT https://api.your-app.com/v1/admin/provider-credentials/posthog ✓
◇ POST https://api.your-app.com/v1/admin/analytics/provision-loop ✓
│
│ PostHog -> Hogsend loop provisioned
│ webhookUrl https://api.your-app.com/v1/webhooks/posthog
│ hogFunctionId hf_…
│ created yes
│
└ connect: posthog connectedA running worker picks the new credential up within about thirty seconds — no restart.
Provisioning is idempotent
Re-running the command is always safe. The provisioner matches the destination by its webhook URL path (/v1/webhooks/posthog), not by name, so a renamed destination, a host migration, or one created by hand is adopted rather than duplicated. It reports what it did: created, updated, or unchanged.
It enforces only what it manages — the URL, method, and body, its two headers (Content-Type and x-posthog-webhook-secret), the identified-events filter, and enabled: true — and preserves what you've added: extra headers, extra filter properties, a rename, a description. Rotate POSTHOG_WEBHOOK_SECRET on the instance, re-run with --provision-only, and the destination's header is reconciled to match.
The destination only forwards identified events ($is_identified = true). Anonymous pageviews never reach your journeys; if you want different filtering, add matchers in PostHog — the provisioner won't remove them.
Permissions can grow later
The grant is front-loaded, so adding a Hogsend feature rarely means re-consenting. When it does, connect-info surfaces a scopeGap — the expected scopes your stored grant is missing — and the connect flow prints a note nudging you to re-authorize. Re-run hogsend connect posthog and approve once more; the new grant replaces the old credential. Because the OAuth client is a CIMD document Hogsend hosts, widening the scope set needs no re-registration on your side. To revoke entirely, remove the grant in PostHog under Settings → Connected applications.
Flags
| Flag | What it does |
|---|---|
--posthog-host | The PostHog app/private host to authorize against, e.g. https://eu.posthog.com or https://us.posthog.com (not the ingestion host). Required when the instance has no PostHog config and you're running non-interactively; otherwise the region prompt or the server's POSTHOG_HOST covers it. |
--provision-only | Skip OAuth entirely; (re-)provision the loop using the already-stored credential. Useful after rotating POSTHOG_WEBHOOK_SECRET or migrating hosts. Unlike the full flow, a provisioning failure here fails the command. |
--no-provision | Stop after storing the credential — wire the loop yourself, or later. |
--no-browser | Don't spawn a browser; print the authorize URL to open yourself. |
--url, --admin-key, --json | The usual global flags. --json emits exactly one document — and never any token material. |
Exit code is 0 whenever a credential is stored, even if provisioning was skipped or failed — the loop can always be finished later with --provision-only.
When it refuses, and why
| What you see | Why | What to do |
|---|---|---|
api_public_url_unreachable | The instance's API_PUBLIC_URL is a loopback address. PostHog Cloud can't deliver to it — and a local instance must never repoint a production destination at localhost. | Run against your deployed instance: hogsend connect posthog --provision-only --url https://your-instance. |
no_credential | --provision-only with nothing stored. | Run the full hogsend connect posthog first. |
not_configured | The instance has no PostHog config and you ran non-interactively, so the CLI can't tell which region to authorize against. | Pass --posthog-host https://eu.posthog.com (or us, or your self-hosted URL), or run interactively and pick from the region prompt. You can also set POSTHOG_HOST on the instance and redeploy. |
oauth_unsupported | Discovery returned 404 — the PostHog instance doesn't ship OAuth. | Use the personal-key fallback. |
port_unavailable | Ports 8423–8425 on 127.0.0.1 are all in use. | Free one and re-run — the callback must land on one of these fixed ports; they're registered in the OAuth client document. |
| Consent denied / timeout / state mismatch | You declined in PostHog, the five-minute wait elapsed, or the callback state didn't match. | Re-run the command. |
One warning worth heeding: if --url is plain http:// to a non-local host, the CLI tells you so — the credential PUT would carry OAuth tokens unencrypted. Use https for remote instances.
What gets stored where
- On the instance: one
provider_credentialsrow — the access token, refresh token, expiry, token endpoint, and granted scopes, encrypted at rest withBETTER_AUTH_SECRET. The engine refreshes the access token about a minute before expiry and keeps the old refresh token whenever a refresh response omits one. Admin routes expose meta only (scopes, expiry, timestamps) — decrypted tokens never leave the server. When the server has noPOSTHOG_WEBHOOK_SECRETin its env, the minted signing secret and the project's public key (phc_) are persisted here too, encrypted — the inbound webhook source reads the secret from this store at request time, and the project key powers the optional outbound capture path. - On your laptop: nothing.
- In PostHog: the webhook destination, with the webhook signing secret riding in a (non-secret) header input. Whether you set
POSTHOG_WEBHOOK_SECRETyourself or let the server mint one, the value ends up in this header. Anyone with access to your PostHog project can read it — same trust domain, deliberate: it's what makes re-runs able to detect secret drift.
With the credential stored, person reads go live: ctx.when resolves per-user timezones from PostHog, person-property conditions evaluate against live profiles, and hogsend doctor's person-reads nudge goes quiet. Person writes and event capture are unaffected — they ride the project key (POSTHOG_API_KEY), with or without OAuth. The credential takes precedence over POSTHOG_PERSONAL_API_KEY when both exist; full resolution order in the reference.
Undoing it
Delete the credential from the instance:
curl -X DELETE \
-H "Authorization: Bearer $ADMIN_API_KEY" \
"https://api.your-app.com/v1/admin/provider-credentials/posthog"Person reads degrade gracefully — back to POSTHOG_PERSONAL_API_KEY if set, otherwise to contact properties and the client default timezone. The PostHog-side destination keeps forwarding events until you disable or delete it in PostHog (Data → Pipeline → Destinations), and the OAuth grant itself can be revoked in PostHog under Settings → Connected applications.
If you rotate BETTER_AUTH_SECRET, the stored credential becomes undecryptable — the meta route returns 409 and the engine degrades as above. DELETE the row and reconnect.
Next steps
PostHog
The full PostHog integration — events in, person reads, engagement out, person writes — set up by one scaffold prompt and one `hogsend connect posthog` command.
Manual Webhook Setup
The by-hand path — 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.