Hogsend is brand new.Chat to Doug
Hogsend
IntegrationsPostHog

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.com

Run 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_URL must be a host PostHog Cloud can reach — provisioning refuses loopback addresses outright, so a local http://localhost:3002 instance can store the credential but never gets a destination pointed at it.
  • An admin key (--admin-key or HOGSEND_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 (or https://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 has POSTHOG_API_KEY/POSTHOG_HOST, the CLI reads the region from there instead and you can skip the flag.

What it does, step by step

  1. Asks the instance for its connect infoGET /v1/admin/analytics/connect-info returns the region (when the server has one), readiness flags, API_PUBLIC_URL, and a scopeGap (expected scopes any stored credential is missing). Secrets never appear in the response, only whether they're configured.
  2. Resolves the region — from --posthog-host, the interactive region prompt, or the server's own POSTHOG_HOST/POSTHOG_API_KEY when it has one. The CLI needs no PostHog env vars to start.
  3. Discovers the OAuth serverGET {your-region}/.well-known/oauth-authorization-server against 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.
  4. Starts a loopback server and opens your browser. PKCE (S256) with a one-shot state value; the callback lands on 127.0.0.1 port 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.
  5. 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 only hog_function:write plus the minted secret) so future read/write features land without forcing a reconnect.
  6. 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.
  7. Stores the credential on the instancePUT /v1/admin/provider-credentials/posthog. The payload is encrypted at rest with BETTER_AUTH_SECRET. Nothing is written on your laptop, and no token ever appears in the CLI's output, JSON or human.
  8. Provisions the loopPOST /v1/admin/analytics/provision-loop runs 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 no POSTHOG_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 project api_token) and stores it for the optional outbound capture path — no phc_ 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 connected

A 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

FlagWhat it does
--posthog-hostThe 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-onlySkip 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-provisionStop after storing the credential — wire the loop yourself, or later.
--no-browserDon't spawn a browser; print the authorize URL to open yourself.
--url, --admin-key, --jsonThe 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 seeWhyWhat to do
api_public_url_unreachableThe 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_configuredThe 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_unsupportedDiscovery returned 404 — the PostHog instance doesn't ship OAuth.Use the personal-key fallback.
port_unavailablePorts 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 mismatchYou 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_credentials row — the access token, refresh token, expiry, token endpoint, and granted scopes, encrypted at rest with BETTER_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 no POSTHOG_WEBHOOK_SECRET in 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_SECRET yourself 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

On this page