Mixpanel webhook adapter
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
adminscope. - 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:
{
"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):
| Field | Mapping |
|---|---|
Canonical event_type | mixpanel.<name> |
| Entity | user ← /body/properties/distinct_id |
| Secondary entities | device ← /body/properties/$device_id |
| Timestamp | /body/properties/time (unix_s) |
| Event id | /body/properties/$insert_id |
| Attributes | members 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 withe_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$ormp_, such as$browser,$os,mp_lib— go to the event'scontext(with sanitized keys:$browser→browser), never to attributes. - Identity.
distinct_idand$device_idare entity references; they,$user_id,user_id,$anon_distinct_idand$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_aliasand$mergeare 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-wisepassthroughoverride changes the shared rule (for exampledeny_source_events).POST /v1/sources/{src_id}/mapping:previewwith{"sample_payload": …}— the exact events the live path would publish, with zero side effects (requiresadmin).
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:
"""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:
#!/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
| Symptom | Cause | Fix |
|---|---|---|
| 401 on deliveries | wrong username or password | the username is always tessera; the password is the source's webhook secret |
| 404 on deliveries | wrong src_id, a disabled source, or an Amplitude source behind a Mixpanel URL | re-check the receiver URL against the source |
400 invalid_argument | a malformed NDJSON line, more than 500 events, or a non-object member | fix the file; split backfills into ≤ 500-line chunks |
events parked as mapping_failed | no distinct_id, or time missing, zero, in milliseconds, or in the future | fix the sender, or override timestamp_format; then replay from the dead-letter view |
a property shows up in context | its name starts with $ or mp_ | expected: reserved properties are context; map it explicitly if you need it as an attribute |