pixellint

Identity · engineers · PMs

distinct_id is who PostHog and Mixpanel join on

Product analytics is not an ads pixel, but the failure is the same: an event with no person is a row nobody can query. The field name changes; the requirement does not.

PostHog

distinct_id is required on a single capture and on every element of batch. Missing, empty, or absent: not ingested. The endpoint still returns 200. Pixellint flags that as vendor.posthog.body.distinct_id.missing or .empty. Send the same distinct_id you use for that person everywhere else in PostHog, including identify.

Anonymous traffic gets a generated id you later alias. Do not mint a new distinct_id on every event because the helper lived in a request scope. A UUID per POST is a new person per click, which looks like huge volume and zero funnels.

PostHog timestamp is a different trap (ISO 8601, not epoch). Identity and time fail independently. Fix distinct_id first or Insights that group by person are fiction even when the clock is right.

Mixpanel

The track payload is a bare array. The person lives at properties.distinct_id, next to properties.token. An empty distinct_id is a hole. Pixellint flags properties.distinct_id.empty when the key is present and empty. $insert_id is for dedup of retries, not a substitute for the person key. Mixing those two is how a retry looks like a new user with a stable insert id, or a known user with a new insert id that double-counts.

Identify calls and original_id / distinct_id merges are how anonymous becomes known. Firing track with a new distinct_id after login splits the funnel in half. Keep the anonymous id, identify to the user id, then track with the user id. Mixpanel documents that merge. Skipping it is a product analytics incident that ads CAPI will not show.

Segment's names

Segment HTTP calls need userId or anonymousId. There is no distinct_id on the wire. Pixellint flags a call with neither as vendor.segment.body.call_needs_an_identifier. Mixpanel and PostHog destinations map those fields into distinct_id. If you only send traits and no id, downstream distinct_id is empty and the destination looks broken.

Keep anonymousId stable until you know the user, then send both userId and the same anonymousId so the destination can alias. Dropping anonymousId on the first identified track is how you orphan the session. The destination cannot merge what it never received.

Batch rows are people too

A PostHog batch with distinct_id on the envelope and not on each row is a common copy from Segment's batch shape. PostHog wants distinct_id on every event object. Mixpanel wants it on every properties object. A helper that sets the id once on the HTTP client does not fill those paths.

Validate a two-row batch where the second row omits the id. pixellint validate json should complain about that row. A single-event fixture will not catch it.

Do not reuse ads cookies as distinct_id

_fbp, _ga, and Amplitude device_id are other graphs. You may seed an anonymous distinct_id from a first-party id you own. You should not change distinct_id every time _fbp rotates under ITP, or funnels reset on Safari while Chrome looks fine.

Pixellint is not affiliated with PostHog, Mixpanel, or Segment. The 200 from capture is not a schema check. Distinct_id missing is the usual reason a backfill 'worked' and Insights stayed empty.

// WRONG: PostHog capture without a person
{ event: 'purchase', properties: { value: 12 } }

// RIGHT: PostHog
{ event: 'purchase', distinct_id: 'user_12345', timestamp: '2026-07-26T06:00:00Z' }

// RIGHT: Segment track
{ type: 'track', event: 'purchase', userId: 'user_12345', anonymousId: 'anon_abc' }

// RIGHT: Mixpanel properties
{ event: 'purchase', properties: { token: 'PROJECT', distinct_id: 'user_12345' } }

Check the artifact

Paste the pixel URL or JSON body into the playground. Same engine as pixellint validate. Nothing leaves the tab.