Skip to main content

Mixpanel webhook adapter

Availability: Available

Mixpanel: Create the source through the API as shown below. The console source catalog does not list this adapter yet.

Send your Mixpanel events to Tessera with zero code — from a Mixpanel webhook, or by pushing Export API batches for history. Your event taxonomy is yours, so the adapter does not enumerate event types: every Mixpanel event becomes a namespaced canonical event (Level Completed → mixpanel.level_completed) about the user identified by distinct_id.

Prerequisites​

  • An API key with the admin scope.
  • A Mixpanel project where you can configure a webhook, or Export API access for backfills.

How authentication works​

Mixpanel does not sign webhook payloads, so the receiver authenticates Mixpanel to Tessera with HTTP Basic authentication over TLS: username tessera, password = the source's webhook secret (Authorization: Basic base64("tessera:<webhook_secret>")). The whole tessera:<secret> pair is compared in constant time before the body is parsed. The secret is envelope-encrypted with Cloud KMS when the source is created and is never returned by any API: reads show {"set": true} in its place. Transport security is TLS plus this credential; there is no payload HMAC. Rotating the secret is a source update and takes effect within 60 seconds.

Create the source​

{
"name": "mixpanel-events",
"kind": "stream",
"config": {
"subtype": "adapter",
"provider": "mixpanel",
"webhook_secret": "REPLACE_WITH_A_LONG_RANDOM_SECRET"
}
}

Your receiver URL ends with the source id from the response:

https://ingest.staging.tessera.zone/v1/adapters/mixpanel/{src_id}

Configure the Mixpanel webhook​

Point the webhook at your receiver URL with HTTP Basic authentication (username tessera, password = your webhook secret). The v1 catalog is pinned to Mixpanel's event object — one JSON object per event, as below; a JSON array, an {"events": [...]} wrapper or NDJSON (Content-Type: application/x-ndjson) of them also works:

mixpanel-event.json
{
"event": "Level Completed",
"properties": {
"time": 1759316400,
"distinct_id": "player-88211",
"$device_id": "mpdev-3a91c2",
"$insert_id": "mp-insert-9e4f71c2-0001",
"$browser": "Chrome",
"$os": "Windows",
"mp_lib": "web",
"level": 12,
"difficulty": "hard",
"score": 48210
}
}

Download connect/mixpanel/mixpanel-event.json — this exact file runs in CI on every change (hosts shown are for this environment).

The fields the catalog reads: event (the event name), properties.distinct_id (the user), properties.$device_id (a secondary device entity), properties.time (epoch seconds), properties.$insert_id (deduplication), and every other property. Deviations from this shape are handled with a mapping override (below).

Event coverage​

Generated from the released mixpanel mapping catalog — the catalog file is the single source of truth:

Catalog version v1 (new sources default to the latest released version; existing sources pin the version they were created with).

Passthrough mapping — every provider event that is not denied and has no explicit entry maps by this rule (<name> is the provider event name through the sanitizer: lowercased, other characters → _, ≤ 64 characters):

FieldMapping
Canonical event_typemixpanel.<name>
Entityuser ← /body/properties/distinct_id
Secondary entitiesdevice ← /body/properties/$device_id
Timestamp/body/properties/time (unix_s)
Event id/body/properties/$insert_id
Attributesmembers of /body/properties; members starting with $, mp_ go to context instead
Context—
Denied provider events$identify, $create_alias, $merge

Pointers resolve against the delivery document {"headers": {…lowercased…}, "body": {…payload…}}; the event-type discriminator is /body/event.

What that means for your data:

  • Event types. The name goes through one sanitizer: lowercased, every run of other characters becomes _, trimmed, prefixed with e_ if it does not start with a letter, at most 64 characters.
  • Properties. Your own properties become attributes (one level of nested object flattens: {"item": {"sku": "a"}} → item_sku; deeper nesting and invalid values are dropped and counted). Mixpanel's reserved properties — names starting with $ or mp_, such as $browser, $os, mp_lib — go to the event's context (with sanitized keys: $browser → browser), never to attributes.
  • Identity. distinct_id and $device_id are entity references; they, $user_id, user_id, $anon_distinct_id and $distinct_id_before_identity — at any level, under any routing rule — never appear as attributes or context: identity is never a feature.
  • Denied by default. $identify, $create_alias and $merge are acknowledged and dropped — Tessera never merges identities.
  • New types are governed. Adapter-produced event types and attributes arrive as draft contract changes; they widen intake, not model input, until you review them.

Retries and idempotency​

$insert_id becomes the canonical event_id, so redeliveries are no-ops downstream; without one, the platform derives a deterministic id from the event, so identical redeliveries still deduplicate. The receiver answers 200 only after every event in the request is durably queued and 503 if any publish fails, so the sender retries the whole request safely. One request carries at most 500 events / 5 MiB; a malformed line rejects the whole request (400) with nothing published.

Events without a distinct_id, or with a missing, zero or future time, are parked in the dead-letter view with reason=mapping_failed (the request still answers 200) and can be replayed after a fix. Make sure every event your project sends carries a distinct_id.

Customize the mapping​

The standard source-mapping routes serve this adapter:

  • GET /v1/sources/{src_id}/mapping — the resolved catalog and your overrides.
  • PUT /v1/sources/{src_id}/mapping — validated overrides: an explicit entry for one event name beats the passthrough rule; {"enabled": false} suppresses an event name; a field-wise passthrough override changes the shared rule (for example deny_source_events).
  • POST /v1/sources/{src_id}/mapping:preview with {"sample_payload": …} — the exact events the live path would publish, with zero side effects (requires admin).

Backfill history​

Mixpanel's raw Export API already returns NDJSON in exactly the shape the catalog reads. Split it into files of at most 500 lines and POST each one to the same receiver with Content-Type: application/x-ndjson and the same Basic credential:

split_ndjson.py
"""Backfill helper: split a Mixpanel Export API file into <=500-event chunks.

Mixpanel's raw export is already newline-delimited ``{"event", "properties"}``
JSON with ``time`` in epoch seconds and ``$insert_id`` — exactly what the v1
catalog reads — so lines are copied verbatim, 500 per chunk file.

Usage: python3 split_ndjson.py <export.ndjson> <out-dir>
"""

import sys
from pathlib import Path

CHUNK = 500 # the receiver's per-request cap


def main(source: str, out_dir: str) -> None:
lines = [line for line in Path(source).read_text().splitlines() if line.strip()]
target = Path(out_dir)
target.mkdir(parents=True, exist_ok=True)
for index in range(0, len(lines), CHUNK):
body = "\n".join(lines[index : index + CHUNK]) + "\n"
(target / f"chunk-{index // CHUNK:05d}.ndjson").write_text(body)
print(f"backfill: {len(lines)} events in {(len(lines) + CHUNK - 1) // CHUNK} chunk(s)")


if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])

Download connect/mixpanel/split_ndjson.py — this exact file runs in CI on every change (hosts shown are for this environment).

Exported events keep their $insert_id, so overlapping a backfill with the live webhook is harmless.

Test and verify​

This sample (run in CI against the local stack) creates the source, sends an event, shows a rejected credential, previews the mapping, backfills an export file and reads the timeline:

01-receive-and-backfill.sh
#!/usr/bin/env bash
# Mixpanel webhook adapter: create the source, receive an event, preview the
# mapping, then backfill exported history through the same receiver.
set -euo pipefail

: "${TESSERA_API_BASE:?e.g. https://api.staging.tessera.zone/v1}"
: "${TESSERA_INGEST_BASE:?e.g. https://ingest.staging.tessera.zone/v1}"
: "${TESSERA_API_KEY:?your tsk_test_… key}"
AUTH=(-H "Authorization: Bearer $TESSERA_API_KEY")

summarize() {
python3 -c 'import json, sys; r = json.load(sys.stdin); print("%s: %s events=%s" % (sys.argv[1], r["outcome"], r["events"]))' "$1"
}

# 1. A random shared credential. In Mixpanel, the webhook uses HTTP Basic
# auth with username "tessera" and this value as the password.
WEBHOOK_SECRET="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"

# 2. Create the adapter source (write-only secret, sealed with Cloud KMS).
SOURCE="$(curl -sS -X POST "$TESSERA_API_BASE/sources" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"name": "mixpanel-events", "kind": "stream",
"config": {"subtype": "adapter", "provider": "mixpanel",
"webhook_secret": "'"$WEBHOOK_SECRET"'"}}')"
SRC_ID="$(printf '%s' "$SOURCE" | python3 -c 'import json, sys; print(json.load(sys.stdin)["source_id"])')"
RECEIVER="$TESSERA_INGEST_BASE/adapters/mixpanel/$SRC_ID"
echo "receiver: $TESSERA_INGEST_BASE/adapters/mixpanel/{src_id}"

# 3. One Mixpanel event, Basic-authenticated as tessera:<secret>.
curl -sS -X POST "$RECEIVER" -u "tessera:$WEBHOOK_SECRET" \
-H "Content-Type: application/json" --data-binary @mixpanel-event.json \
| summarize event

# A wrong password is rejected before the body is even parsed.
WRONG_SECRET="$(python3 -c 'import secrets; print(secrets.token_urlsafe(16))')"
STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$RECEIVER" \
-u "tessera:$WRONG_SECRET" \
-H "Content-Type: application/json" --data-binary @mixpanel-event.json)"
echo "wrong credential: $STATUS"

# 4. Preview: reserved $-prefixed and mp_* properties route to context.
curl -sS -X POST "$TESSERA_API_BASE/sources/$SRC_ID/mapping:preview" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d "{\"sample_payload\": $(cat mixpanel-event.json)}" \
| python3 -c '
import json, sys
event = json.load(sys.stdin)["events"][0]
print("preview:", event["event_type"])
print("attributes:", ",".join(sorted(event["attributes"])))
print("context:", ",".join(sorted(event["context"])))
'

# 5. Backfill: the Export API's NDJSON, chunked to <= 500 events per request.
# Denied identity events in the export ($identify) are acknowledged and
# skipped; everything else publishes.
CHUNKS="$(mktemp -d)"
python3 split_ndjson.py mixpanel-export.ndjson "$CHUNKS"
for chunk in "$CHUNKS"/chunk-*.ndjson; do
curl -sS -X POST "$RECEIVER" -u "tessera:$WEBHOOK_SECRET" \
-H "Content-Type: application/x-ndjson" --data-binary @"$chunk" \
| summarize backfill-chunk
done

# 6. Live + backfilled events land on the player's timeline.
COUNT=0
for _ in $(seq 1 30); do
COUNT="$(curl -sS "$TESSERA_API_BASE/entities/user/player-88211/events" "${AUTH[@]}" \
| python3 -c 'import json, sys; print(len(json.load(sys.stdin)["data"]))')"
if [ "$COUNT" -ge 4 ]; then break; fi
sleep 1
done
echo "timeline events for player-88211: $COUNT"

Download connect/mixpanel/01-receive-and-backfill.sh — this exact file runs in CI on every change (hosts shown are for this environment).

Troubleshooting​

SymptomCauseFix
401 on deliverieswrong username or passwordthe username is always tessera; the password is the source's webhook secret
404 on deliverieswrong src_id, a disabled source, or an Amplitude source behind a Mixpanel URLre-check the receiver URL against the source
400 invalid_argumenta malformed NDJSON line, more than 500 events, or a non-object memberfix the file; split backfills into ≤ 500-line chunks
events parked as mapping_failedno distinct_id, or time missing, zero, in milliseconds, or in the futurefix the sender, or override timestamp_format; then replay from the dead-letter view
a property shows up in contextits name starts with $ or mp_expected: reserved properties are context; map it explicitly if you need it as an attribute