Event Transport
How analytics events travel from the Percus Player iframe, through the host SDK, to the ingest endpoint. The path is loss-tolerant, ad-blocker–aware, and gated by consent.
Two-channel architecture
The player iframe and the host page communicate over two independent postMessage channels, both targeted at parentWindow with the player's parentOrigin:
| Channel | Direction | Purpose | Bundled in |
|---|---|---|---|
Control (v: 1, types READY / PROGRESS / COMPLETE / WARN / CTA / ERROR) | Player → host | Drives the existing onReady / onProgress / onComplete / onWarn / onCta callbacks the SDK already exposes. | percus-embed.global.js (bootstrap) |
Analytics (PERCUS_ANALYTICS_EVENT_V1) | Player → host | Carries player-side moments to the ingest pipeline. | percus-tracking.global.js (lazy) |
The two channels share a transport (postMessage) but are versioned independently. The control channel keeps its existing v1 contract; the analytics channel can evolve without touching it.
Once the host SDK receives an analytics message it enriches the envelope with the page-level analytics context (organization_id, project_id, template_id, template_version, session_id, consent, optionally viewer_hash) and POSTs a batch to the ingest endpoint.
┌──────────────┐ PERCUS_ANALYTICS_EVENT_V1 ┌──────────────┐ POST /analytics/v1/events
│ Player │ ───────postMessage────────▶ │ Host SDK │ ──────fetch (keepalive)─────▶ ingest
│ (iframe) │ │ bridge │
└──────────────┘ └──────────────┘
│
queue ▸ retry ▸ batch ▸ Bearer auth
The postMessage contract
The player emits one message per analytics-worthy moment:
interface PercusAnalyticsEventMessage {
type: "PERCUS_ANALYTICS_EVENT_V1";
eventType:
| "session.started"
| "video.played"
| "video.paused"
| "video.progressed"
| "video.completed"
| "video.incomplete"
| "video.errored"
| "autoplay.failed"
| "cta.clicked"
| "interaction.engaged"
| "chapter.entered"
| "chapter.exited"
| "consent.accepted"
| "consent.declined";
occurredAt: string; // ISO-8601 with millisecond precision, player clock
playbackId: number | null; // positive integer ≥ 1; null outside any playback
payload: Record<string, unknown>; // event-type-specific (see schema)
}
The SDK's event bridge validates every incoming message:
- Source iframe —
event.source === expectedIframe.contentWindow. Messages from any other iframe on the page are ignored. - Origin —
event.origin === expectedOrigin(the player's deployed origin). Cross-origin spoofing attempts are dropped. - Shape —
eventTypemust be one of the 14 schema literals;playbackIdmust benullor a positive integer;occurredAtandpayloadmust be non-empty strings/objects respectively.
A message that fails any of these checks is silently dropped — it doesn't reach the queue and never costs a network round-trip.
Wire event types
When and what the player emits today:
| Event type | Triggered when | Payload (player-supplied) |
|---|---|---|
session.started | Player completes INIT for a fresh session | {} |
video.played | First/next playback transitions to playing | { position_ms } |
video.paused | Playback transitions to not playing | { position_ms } |
video.progressed | Crosses 25 / 50 / 75% (highest newly-crossed only, per playback) | { position_ms, percent } |
video.completed | Media reaches end | { duration_ms, percent: 100 } |
video.errored | Runtime emits ERROR | { error_code } |
autoplay.failed | WARN with code AUTOPLAY_BLOCKED | { reason } |
cta.clicked | CTA click | { cta_id, cta_name, position_ms } |
playbackId is owned by the player. It starts at 1 on the first video.played and increments on the next video.played after a video.completed. Quartile state resets per playback.
consent.accepted / consent.declinedThese two events are not emitted by the player. They originate in the bootstrap and ship via a dedicated single-shot beacon — see the SDK Lazy Load page.
Queue and retry semantics
Once the bridge enqueues an envelope, the SDK is in charge of getting it to the ingest endpoint. Behavior is:
- Microtask-batched. Events enqueued synchronously collect into one POST.
- Single-flight. While a batch is in flight, new events buffer; the worker doesn't start a second POST in parallel. Events that arrive mid-send ride the next batch.
- Backoff schedule. After a retryable failure, the queue waits
200 × nms before the n-th retry: 200, 400, 600, 800 ms. - Max 5 attempts. On the 5th failure the batch is dropped — a permanently-broken endpoint can never wedge the queue.
fetch(keepalive: true). A batch fired right before tab close still completes (modern replacement fornavigator.sendBeacon, which can't set bearer headers).- Bearer auth. Every POST carries
Authorization: Bearer <analytics-ingest-token>from the manifest. The token is signed with the same secret the ingest authenticator validates against; the campaign mints it with a 24 h default TTL (configurable 15 m – 48 h) so it outlives the much shorter embed-session token.
Retry classification
Status codes the SDK treats as retryable (the queue backs off and tries again):
| Status | Why |
|---|---|
Any 5xx | Server is briefly broken |
408 Request Timeout | Transient |
429 Too Many Requests | Overload signal — back off |
| Network error (DNS / TCP / CORS) | Transient |
Everything else (400, 401, 403, 404, 413, 422, …) is terminal — a bad token, scheme violation, or rejected payload won't recover by retrying, so the batch is dropped immediately to free the queue.
Server-side enrichment
The ingest endpoint validates the client envelope against event-schema-v1.json before any enrichment, so the client must already pass schema validation. Once validation succeeds the server overwrites:
organization_idandproject_idfrom the bearer token's claims (client-supplied values are advisory).devicefrom the request'sUser-Agent(parsed into{ type, os, browser }; the raw UA is discarded).geofrom theCloudFront-Viewer-Countryheader.received_atfrom the ingest clock.
Client-supplied template_id, template_version, session_id, playback_id, viewer_hash, referrer_host, occurred_at, and consent are preserved as sent.
Origin scoping
The Player iframe's parentOrigin is set from the first INIT message (which has a verified event.origin). Every analytics message thereafter targets that exact origin — so a host page navigating to a different document, or a malicious iframe trying to forge messages, can't cross-contaminate. The SDK bridge enforces the same check on the receiving side.
If the host page has restricted the share via allowed_origins on the embed session token, the ingest endpoint additionally rejects POSTs whose Origin header isn't in that list (HTTP 403).