# PostHog Packaging Analytics

Stream entitlement denial events to PostHog with explicit identity mapping and bounded volume.

<!-- docs-source -->
Source: https://learning.monetizekit.app/docs/guides/posthog-analytics
<!-- /docs-source -->

Every denied entitlement check is a commercial moment: a customer just hit the edge of what their plan allows. The **packaged destination** (Integrations → PostHog) streams each denial into your PostHog project as an `mk_denial` event carrying the feature, the machine-readable reason, and the plans that would have granted it — the raw material for conversion funnels, feature-wall rankings, and packaging experiments. For richer client-side instrumentation (including *allowed* decisions), the SDK's `DecisionObserver` extension point is the manual half.

> [!NOTE] Analytics can never affect enforcement
>
> Emission is queue-isolated: the entitlement decision path only enqueues a durable delivery row and never waits on PostHog. Capture failures are retried by the hourly drainer and are visible in the configure dialog — a PostHog outage cannot slow down or change a single decision.

## Step 1 — Connect your PostHog project

In **Integrations → PostHog → Connect**, paste your project API key (`phc_…`, from PostHog → Project settings). This is the ingestion key — MonetizeKit never asks for a personal API key and never reads from your project. The key is validated against PostHog's read-only `/decide` endpoint, which authenticates it *without creating any event, person, or group*, then stored envelope-encrypted and never re-displayed. Pick your ingestion host (us.i.posthog.com or eu.i.posthog.com) and make two explicit identity choices:

| Identity mapping | What each event carries | Fits when |
| --- | --- | --- |
| `group (default)` | `$groups: { account } + $process_person_profile: false` | B2B / org-backed customers. Events land as group analytics; person profiles are never created for accounts. |
| `person` | The customer id as the person distinct_id | B2C products where each MonetizeKit customer is one human. |

The mapping choices are exactly group and person. Group-mapped events set `$process_person_profile: false` on the wire, so a group-mapped workspace can never accidentally create person profiles for accounts. Separately, choose how customer identifiers travel:

| Identifier mode | Effect |
| --- | --- |
| `full` | Customer ids are sent as-is (default) — easiest to join against your own data. |
| `hashed` | Ids are replaced with a stable SHA-256 hash — cohorts still work, raw ids never leave. |
| `omitted` | No customer identifier at all — events are anonymous volume only. |

## Step 2 — Preview before anything is sent

Nothing streams until you confirm. Before connecting, the wizard shows two things: a **volume projection** computed from your workspace's own denial history over the last 30 days (so the monthly cap you set is grounded in your real traffic, not a guess), and the **exact event** PostHog will receive — rendered by the same code the delivery pipeline uses, from your most recent real denial when one exists:

```json
{
  "event": "mk_denial",
  "distinct_id": "cus_sample",
  "properties": {
    "feature_key": "advanced_reports",
    "reason_code": "not_in_plan",
    "granted_by_plans": [
      "Growth",
      "Scale"
    ],
    "environment": "production",
    "customer_id": "cus_sample",
    "$process_person_profile": false,
    "$groups": {
      "account": "cus_sample"
    }
  },
  "timestamp": "2026-07-28T12:00:00.000Z"
}
```

| Property | Meaning |
| --- | --- |
| `feature_key` | The feature the customer was denied. |
| `reason_code` | Machine-readable cause (not_in_plan, limit_reached, unknown_feature, …). |
| `granted_by_plans` | Published plans that would grant (or raise the limit for) the feature. |
| `environment` | The environment the check ran in (production, test, …). |
| `customer_id` | The customer identifier — full, hashed, or absent per your identifier mode. |
| `collapsed_count` | How many identical denials this delivery represents (collapse window). |

`granted_by_plans` is the actionable part: the published plans that would have granted (or raised the limit for) the denied feature — the same signal your app can use for upgrade prompts via the API's `grantedByPlans` field.

## Volume controls — caps and collapsing

Two mechanisms keep a hot denial loop from becoming a PostHog bill surprise:

- **Collapse window** — identical denials (same customer + feature + reason) inside a 300-second window merge into one delivery with a `collapsed_count` property, so a retry storm is one event, not a thousand.
- **Monthly cap** — you set a hard events-per-month cap at connect time (default 50,000). At the cap, delivery stops *visibly*: further events are marked `skipped_capped` (never silently dropped), the destination flips to `capped`, and you get a notification. Delivery resumes automatically when the month rolls over.

> [!TIP] Delivery status is always inspectable
>
> Every queued event has one of pending, delivered, failed, skipped_capped status. The configure dialog shows sent-this-month against the cap, plus pending and failed queue counts, with a manual *Drain now* action. Failed deliveries retry on the hourly drain.

## Build the funnel in PostHog

The event is deliberately minimal so it composes with your existing PostHog data. The highest-signal starting point is denial → upgrade:

```bash
# In PostHog: Insights -> Funnel
# Step 1: mk_denial            (customer hit a packaging wall)
# Step 2: subscription upgraded    (from your billing events)
#
# Break down step 1 by "feature_key" to rank which walls convert —
# and by "reason_code" to separate plan gaps (not_in_plan) from
# limit pressure (limit_reached).
```

## Manual pattern — every decision via the SDK's `DecisionObserver`

The packaged destination streams denials from the platform side. When you also want *allowed* decisions, cache/degradation visibility, or custom event names, attach an observer in your app (requires `@monetizekit/node` ≥ 0.2.0):

```typescript
import { PostHog } from "posthog-node";
import { MonetizeKit, type DecisionObserver } from "@monetizekit/node";

const posthog = new PostHog(process.env.POSTHOG_PROJECT_KEY, {
  host: "https://us.i.posthog.com",
});

const posthogObserver: DecisionObserver = {
  onDecision(event) {
    posthog.capture({
      distinctId: event.customerId,
      event: event.allowed ? "mk_check_allowed" : "mk_check_denied",
      properties: {
        feature_key: event.featureKey,
        reason_code: event.reasonCode,
        cached: event.cached,
        degraded: event.degraded,
        latency_ms: event.latencyMs,
      },
    });
  },
};

export const mk = new MonetizeKit({
  apiKey: process.env.MONETIZEKIT_API_KEY,
  observers: [posthogObserver],
});
```

> [!WARNING] Running both? Use distinct event names
>
> The packaged destination owns `mk_denial`. If you also stream denials from an observer, name them differently (as above) — otherwise your funnels will double-count every denial.

## FAQ

### Will this create persons in my PostHog project?

Not with the default `group` mapping — every event carries `$process_person_profile: false`, so accounts appear only as group analytics. Person profiles are created only if you explicitly choose `person` mapping at connect time.

### Can a PostHog outage slow down entitlement checks?

No. The decision path only writes a durable queue row (fire-and-forget) — delivery to PostHog happens in a separate drainer. If PostHog is down, rows sit as `failed` and retry on the next hourly drain; decisions are unaffected in latency and outcome.

### What about customer PII?

Events never include names or emails — only the customer id, and only in the form you chose: full, SHA-256 hashed, or omitted entirely. Switching the identifier mode later applies to new events (already-delivered events are in your PostHog project, under your retention controls).

### Which events are available?

The packaged event types today are exactly mk_denial. The destination stores an explicit per-event-type toggle list, so additional packaging events (trials, upgrades, credit exhaustion) can ship individually — you opt into each, and nothing new is ever streamed without your action.

## Related guides

### Node SDK Integration

The DecisionObserver extension point the manual pattern builds on.

[Open guide](/docs/guides/sdk-node)

### Entitlement Evaluation Patterns

The reason codes and grantedByPlans field the events carry.

[Open guide](/docs/guides/entitlement-patterns)

### Clerk Identity Integration

Keep customer identities synced so distinct ids stay meaningful.

[Open guide](/docs/guides/clerk-identity)