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.
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:
{
"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_countproperty, 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 tocapped, and you get a notification. Delivery resumes automatically when the month rolls over.
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:
# 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):
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],
});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 guideEntitlement Evaluation Patterns
The reason codes and grantedByPlans field the events carry.
Open guideClerk Identity Integration
Keep customer identities synced so distinct ids stay meaningful.
Open guide