Analytics access & identity
The provider-neutral analytics contract, PostHog's two-credential model (and why it exists), person reads vs writes, and how the contactKey identity loop joins your site, your emails, and your analytics into one person.
Hogsend talks to your analytics platform through one neutral contract — the
AnalyticsProvider — and PostHog is the reference implementation, not the
architecture. This page covers what each credential can do, why PostHog
needs two of them, and how Hogsend's identity loop keeps your site sessions,
email engagement, and analytics persons joined on a single key.
This page is about credentials and identity. For the end-to-end setup —
including hogsend connect posthog,
which wires person reads and the inbound webhook in one command — see
the PostHog integration; for fanning Hogsend's
event stream out to PostHog and other tools, see
Outbound destinations.
The two-credential model (and why it isn't Hogsend ceremony)
PostHog splits its API in half, on purpose:
| Credential | What it is | What it can do | What it can NEVER do |
|---|---|---|---|
Project API key (phc_…) | Public. Ships in every browser bundle on your site. | Capture events, evaluate feature flags — and therefore person property WRITES (they ride the capture pipeline as $set / $set_once / $unset). | Read anything. |
| Personal API key | Secret. Created per-user, scoped per-resource and per-project. | Private-API reads (and more, per its scopes). | Nothing it's scoped for — scope it tightly. |
The project key is write-only by PostHog's design: anyone can View Source on your site and copy it, so if it could read, anyone could dump your entire persons database. Every read goes through the private API with a personal API key instead. This is PostHog protecting your users' data — Hogsend just inherits the split, and degrades gracefully when the read credential is absent.
What Hogsend uses each credential for
# Capture + person WRITES (the propagation rail) — no-op if unset.
POSTHOG_API_KEY=phc_...
POSTHOG_HOST=https://eu.i.posthog.com
# Person READS — per-user timezone resolution (ctx.when), property conditions.
POSTHOG_PERSONAL_API_KEY=...
# Optional overrides — both are derived/discovered automatically:
# POSTHOG_PROJECT_ID=12345
# POSTHOG_PRIVATE_HOST=https://eu.posthog.com| Capability | Needs | Without it |
|---|---|---|
Event capture (getPostHog().captureEvent, destinations) | POSTHOG_API_KEY | Silent no-op |
Person writes (setPersonProperties, bucket mirror) | POSTHOG_API_KEY | Silent no-op |
Person reads (ctx.when timezone, getPersonProperties) | POSTHOG_PERSONAL_API_KEY | Soft-fails to contact properties → client default timezone; surfaced once at boot and by hogsend doctor |
Two details the engine handles for you, because they're easy to get wrong:
- The private API lives on a different host. Capture goes to the
ingestion host (
eu.i.posthog.com); reads go to the app host (eu.posthog.com). Hogsend derives the private host by stripping the.i.label — override withPOSTHOG_PRIVATE_HOSTfor self-hosted layouts. - Reads are environment-scoped. Hogsend discovers your project id once
via
GET /api/projects/@current/and caches it — setPOSTHOG_PROJECT_IDto skip discovery.
At scaffold time
create-hogsend asks "Are you using PostHog?" — hand it your project
API key (phc_…) and region (EU Cloud, US Cloud, or a custom host URL) and
the scaffold writes POSTHOG_API_KEY + POSTHOG_HOST as active values,
switches on ENABLE_POSTHOG_DESTINATION (the email lifecycle fans out to
PostHog durably), and mints a POSTHOG_WEBHOOK_SECRET for the inbound
webhook source. Non-interactive:
--posthog-key phc_… --posthog-host https://eu.i.posthog.com
(--no-posthog skips the prompt; skipping leaves the env untouched).
That covers capture and person writes from day one. Once the app is
deployed, hogsend connect posthog
finishes the loop — it wires person reads (no personal key needed) and the
PostHog→Hogsend event loop, in one command with one browser consent click.
Creating the personal API key
- PostHog → Settings → Personal API keys → Create personal API key (PostHog will ask you to re-authenticate).
- Scope it: Organization & project access → Projects → pick your
project. Scopes → Person: Write (write includes read) and
Project: Read (for the
@currentproject discovery). - Copy the key (shown once) into
POSTHOG_PERSONAL_API_KEYon your api and worker.
Resist "All access" — the key acts as you. Person: Write + Project: Read is everything Hogsend needs.
The identity loop: one person across site, email, and analytics
Hogsend's canonical identity is the contact key — external_id ?? anonymous_id ?? id, resolved by the engine's alias-aware identity system.
That key is deliberately the same one everywhere it matters:
POST /v1/eventsreturns it (contactKey, engine ≥0.18). Your site's subscribe endpoint can hand it to the browser, which callsposthog.identify(contactKey)— an opaque id, zero PII in PostHog.- Tracked email links carry it (opt-in
TRACKING_IDENTITY_TOKEN): a click lands on your site with a short-livedhs_ttoken, exchanged atPOST /v1/t/identifyfor the same key →posthog.identifyagain. Email clicks and web sessions become one person. - Outbound destinations emit it as
userId— so the email lifecycle events Hogsend fans out to PostHog land on that same person. - It round-trips. Events forwarded back from PostHog (the webhook source) under that key resolve to the same contact — never a duplicate.
The result: the anonymous visitor who subscribed, the contact your journeys email, the person whose opens/clicks land in PostHog, and the PostHog events that trigger your journeys are all one identity, joined on a key you own.
Person writes: the propagation rail
setPersonProperties on the provider (or the legacy identify() shim) puts
contact truth onto the analytics person — plan, role, lifecycle stage — using
only the project key:
import { getPostHog } from "@hogsend/engine";
getPostHog()?.identify(user.id, { plan: "pro" });Buckets can mirror membership automatically
(syncToPostHog: true), and the posthog outbound
destination propagates contact changes
automatically when its config.syncPersons is true (the seeded destination
has it on by default): contact.created / contact.updated become $set
captures of the contact's properties under its canonical key, and a
full unsubscribe sets hogsend_unsubscribed: true — only properties
travel, never email or any identifier.
Provider-neutral by contract
The engine speaks AnalyticsProvider (from @hogsend/core): meta,
capabilities, getPersonProperties, setPersonProperties, capture.
PostHog ships as createPostHogProvider from @hogsend/plugin-posthog; any
platform that can read/write a person and capture an event can implement the
same contract and plug into createHogsendClient:
import { defineAnalyticsProvider } from "@hogsend/engine";
const myProvider = defineAnalyticsProvider({
meta: { id: "my-analytics", name: "My analytics" },
capabilities: { personReads: true, personWrites: true },
async getPersonProperties(distinctId) { /* … */ return {}; },
async setPersonProperties({ distinctId, set, setOnce, unset }) { /* … */ },
capture({ distinctId, event, properties }) { /* … */ },
});
const client = createHogsendClient({
analytics: { provider: myProvider },
});The identity loop above doesn't change — the contact key is Hogsend's, not PostHog's, so step 1, 2 and 4 work identically with any provider; only the capture/read wires swap.
Destinations
Author a code-first outbound destination with defineDestination() — a delivery-time transform that fans your event catalog out to a custom CRM, warehouse, or internal bus, reusing the engine's durable retry/backoff/DLQ delivery.
Database & Migrations
Add your own tables to a Hogsend app with Drizzle, run two-track migrations, and read schema drift off the health endpoint.