Rovenue SDK
Funnels

Events & Analytics

Device and web funnel events, the one outbox-to-ClickHouse pipeline, the Sessions tab, and the funnel webhooks.

Two producers, one pipeline. A native funnel is presented on a device that reports what happened; a web funnel runs on the server, which records what it did. Both end up as rows in the same ClickHouse table, split by the target dimension — and only some of them ever reach a webhook.

Device events

The device reports four wire names over POST /v1/events, each mapped to the funnel vocabulary in exactly one place (apps/api/src/lib/funnel-event-shape.ts:86-91):

Device wire nameBecomes
funnel_viewfunnel.session.started
funnel_screen_viewfunnel.session.advanced
funnel_completefunnel.session.completed
funnel_dismissfunnel.session.dismissed

The list is closed: any other funnel_<x> never enters the session vocabulary and is kept under funnel.device.<x> instead (apps/api/src/lib/funnel-event-shape.ts:71, apps/api/src/lib/funnel-event-shape.ts:86-91). A screen view is an arrival — there is no from, because the device names only the screen it landed on (apps/api/src/lib/funnel-event-shape.ts:336-340).

Every device event carries funnelContext, and funnelContext.sessionId is required and non-empty (apps/api/src/routes/v1/events.ts:99-113): one presentation mints one session id, and every event from opening to close or finish carries it. A funnel_* body without its context is refused at the boundary, never stored (apps/api/src/routes/v1/events.ts:126-137). The route files the row under its own aggregate, FUNNEL_DEVICE_EVENT (apps/api/src/routes/v1/events.ts:144-152) — never folded into the retired paywall aggregate, so onboarding screens cannot inflate a paywall conversion rate.

{
  "eventType": "funnel_view",
  "eventId": "<client-generated idempotency key>",
  "subscriberId": "<the device's Rovenue id>",
  "occurredAt": "2026-09-24T12:00:00.000Z",
  "funnelContext": {
    "funnelId": "<the funnels row id from the placement envelope>",
    "sessionId": "<one id per presentation>",
    "placementId": "<the placement identifier>",
    "placementRevision": 7,
    "revision": 3,
    "screenId": "welcome",
    "variantId": "<variant id when drawn from an experiment>",
    "experimentKey": "onboarding-copy-test"
  }
}

funnelId is the served funnel's row id — the placement envelope carries it because a native funnel has no slug (apps/api/src/lib/placement-resolution.ts:139-143). Restoring a session from state_json emits nothing and keeps the same sessionId; dismiss() emits the one terminal event the device owns, funnel_dismiss.

Web session events

The web runner emits the same vocabulary minus the one event it cannot produce: everything in FUNNEL_EVENT_TYPE except DISMISSED (apps/api/src/lib/funnel-event-shape.ts:31-52, apps/api/src/lib/funnel-event-shape.ts:56-57) — started, advanced, moved_back, paid, completed, abandoned, and custom_action, plus the claim_token mechanics. abandoned is a server-side inactivity timeout written by the funnel-abandoner worker (apps/api/src/workers/funnel-abandoner.ts:40-46), never a device close; dismissed is the reverse — a device close, never a timeout (apps/api/src/services/funnel/analytics.ts:44-53). A backward move is moved_back, not advanced, so going back to an already-reached screen never counts as a second arrival (apps/api/src/services/funnel/analytics.ts:131-156).

Every web payload is stamped target: "web" at emission, unconditionally (apps/api/src/services/funnel/outbox.ts:33-56) — never read off the funnel's current, mutable target, so converting a funnel cannot rewrite which side its old sessions belong to (apps/api/src/services/funnel/analytics.ts:60-86).

The one pipeline

Both aggregates share the rovenue.funnel topic: FUNNEL and FUNNEL_DEVICE_EVENT map to it in the same table (apps/api/src/lib/outbox-topics.ts:25-33), and the dispatcher picks the shaper by aggregate, never by topic (apps/api/src/workers/outbox-dispatcher.ts:142-160). A web row goes verbatim with eventId equal to its outbox id; a device row is reshaped from its raw client body into the same wire, with a content-hash id so a retried POST dedups (apps/api/src/workers/outbox-dispatcher.ts:142-160, apps/api/src/lib/funnel-event-shape.ts:269-284).

From there the path is fixed: outbox → Redpanda → the Kafka Engine table (packages/db/clickhouse/migrations/0026_funnel_events_kafka_engine.sql:40-46) → the materialized view into raw_funnel_events (packages/db/clickhouse/migrations/0029_funnel_events_target.sql:63-107). A message that predates target falls back to 'web' in the view, not by column default (packages/db/clickhouse/migrations/0029_funnel_events_target.sql:86-95). Reads go through FINAL over the deduped raw table, so an at-least-once redelivery cannot inflate a count (apps/api/src/services/funnel/analytics.ts:14-21). One event aggregate with a target dimension, one raw table, one MV — that unification is spec §5 (docs/superpowers/specs/2026-09-21-funnels-flows-unification-design.md:222-228).

The Sessions tab

The builder's Sessions tab reads this pipeline (shipped in wave 7, Task 8 — docs/superpowers/plans/2026-09-24-funnels-unification-wave-7-rename.md:477, docs/superpowers/plans/2026-09-24-funnels-unification-wave-7-rename.md:484). Each screen row shows two numbers: sessions next to arrivals (apps/dashboard/src/components/funnel-builder/sessions-tab.tsx:337). They differ on purpose. An arrival is a row: started names the screen a session opened on, advanced names the one a forward move landed on (apps/api/src/services/funnel/analytics.ts:131-156). sessions is a uniq over session ids over the whole window, read off the raw table rather than summed from the daily view — adding per-day uniqs would count a session that spanned midnight twice (apps/api/src/services/funnel/analytics.ts:181-194).

Two details follow from that. The entry is the document's first page — the screen the resolver starts every session at — not screens[0], which is the busiest screen, since the API orders busiest-first (apps/dashboard/src/components/funnel-builder/sessions-tab.tsx:253-266). And dismissed appears only when non-zero, so a web-only funnel's history never grows a permanent "0 dismissed" (apps/dashboard/src/components/funnel-builder/sessions-tab.tsx:267-271). With no analytics store configured there is no history at all — the live numbers above it come from Postgres, which knows where sessions are, not where they went (apps/dashboard/src/components/funnel-builder/sessions-tab.tsx:240-250, apps/api/src/services/funnel/analytics.ts:6-21).

Webhooks

Seven funnel keys reach webhooks, and only the catch-all providers offer them — Custom Webhook, Slack, Discord (packages/shared/src/integrations.ts:20-28, apps/docs/content/docs/integrations/outbound-webhooks.mdx:154): started, advanced, paid, completed, abandoned, dismissed, and custom_action. Deliberately absent: moved_back (analytics hygiene, not a business moment), the claim_token mechanics, and any funnel.device.* name (packages/shared/src/integrations.ts:7-14, apps/api/src/services/integrations-fanout/consumer.ts:321-326). paid carries no amount — money stays on revenue.* (packages/shared/src/integrations.ts:16-18); abandoned is web-only and dismissed native-only, the same split as above. The full per-key table lives on Outbound Webhooks.

data is a curated camelCase object with sessionId taken from the wire's aggregate id and omitted when empty (apps/api/src/services/integrations-fanout/consumer.ts:286-296, apps/docs/content/docs/integrations/outbound-webhooks.mdx:69):

{
  "id": "<the wire eventId — the outbox id for web, the content hash for native>",
  "type": "funnel.session.started",
  "created": "2026-09-24T12:00:00.000Z",
  "apiVersion": "2026-08-24",
  "projectId": "<the project id>",
  "data": {
    "sessionId": "<the presentation id>",
    "funnelId": "<the funnels row id>",
    "target": "web",
    "pageId": "welcome"
  }
}

Native funnel webhooks are client-asserted. A device event is submitted with the project's public SDK key — the same key that ships inside the app binary — so anyone holding it can submit an event that never happened on a real device (apps/docs/content/docs/integrations/outbound-webhooks.mdx:113). Web funnel events and every revenue.* event are produced server-side and carry no such exposure. Never treat a native funnel webhook as proof of anything a client could fake; for a trustworthy signal use revenue.* or a web funnel event instead.

On this page