Skip to main content

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:

ChannelDirectionPurposeBundled in
Control (v: 1, types READY / PROGRESS / COMPLETE / WARN / CTA / ERROR)Player → hostDrives the existing onReady / onProgress / onComplete / onWarn / onCta callbacks the SDK already exposes.percus-embed.global.js (bootstrap)
Analytics (PERCUS_ANALYTICS_EVENT_V1)Player → hostCarries 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:

  1. Source iframeevent.source === expectedIframe.contentWindow. Messages from any other iframe on the page are ignored.
  2. Originevent.origin === expectedOrigin (the player's deployed origin). Cross-origin spoofing attempts are dropped.
  3. ShapeeventType must be one of the 14 schema literals; playbackId must be null or a positive integer; occurredAt and payload must 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 typeTriggered whenPayload (player-supplied)
session.startedPlayer completes INIT for a fresh session{}
video.playedFirst/next playback transitions to playing{ position_ms }
video.pausedPlayback transitions to not playing{ position_ms }
video.progressedCrosses 25 / 50 / 75% (highest newly-crossed only, per playback){ position_ms, percent }
video.completedMedia reaches end{ duration_ms, percent: 100 }
video.erroredRuntime emits ERROR{ error_code }
autoplay.failedWARN with code AUTOPLAY_BLOCKED{ reason }
cta.clickedCTA 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.declined

These 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 × n ms 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 for navigator.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):

StatusWhy
Any 5xxServer is briefly broken
408 Request TimeoutTransient
429 Too Many RequestsOverload 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_id and project_id from the bearer token's claims (client-supplied values are advisory).
  • device from the request's User-Agent (parsed into { type, os, browser }; the raw UA is discarded).
  • geo from the CloudFront-Viewer-Country header.
  • received_at from 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).