A shared vocabulary for the objects Admaxxer models. Read this before querying /api/v1/*, before reading dashboard SQL, or before asking the Claude agent a "give me X by Y" question.
TL;DR: Admaxxer's data model is built on 10 canonical entities: Workspace, Website, Visitor, Session, Pageview, Goal, Payment, AdAccount, the Campaign / AdSet / Ad hierarchy, and Subscription. Workspace is the multi-tenant root; everything else carries a workspace_id. Hot append-only entities (Pageview, Goal, Payment, ad insights) live in Tinybird; structural state (Workspace, User, AdAccount config) lives in Neon Postgres. Naming is snake_case in storage, camelCase at the JS/TS layer; external IDs from third-party systems carry an external_* prefix. The same 10 entities surface unchanged in the UI, in /api/v1/*, and in every Claude agent tool call.
A data ontology is the shared vocabulary your stack uses to refer to the entities and relationships in your business. When the dashboard says "Revenue by Ad", the API says GET /api/v1/ads/{id}/revenue, and the Claude agent says get_ad_revenue(ad_id, ...) — they're all referring to the same Ad entity, joined the same way to the same Payment entity. Drift in that vocabulary is one of the most common analytics-platform failure modes (Snowflake's "every team has its own definition of MRR" problem).
Admaxxer publishes its ontology as a documented contract. Every entity name, every field name, every join key is listed here. If you're building against the API, integrating with a downstream BI tool, or asking the Claude agent a complex question, this is the source of truth.
The top-level container. Every other entity in the system carries a workspace_id column for isolation. Every API request resolves to one workspace; cross-workspace queries are explicitly disallowed.
workspaces in shared/schema.ts.id (UUID), name, ownerId, subscriptionPlan (one of AD_STARTER, AD_GROWTH, AD_PRO, AD_AGENCY, AD_ENTERPRISE), reportingCurrency, reportingTimezone, createdAt.workspace_id. Joined to workspaceMembers for team-seat queries.A single domain (or subdomain set) the merchant runs the pixel on. One workspace can have many websites — useful for brands running multiple Shopify stores, for agencies, or for staging-vs-production split.
pixel_websites.website_id (slug-cased, prefixed admx_* — e.g. admx_acme_us), workspace_id, host (the production hostname), tracking_id (the pixel snippet ID), currency, timezone.(workspace_id, website_id). Required identity check that distinguishes Admaxxer Neon from sibling projects (GL#274).An anonymous identity tied to the visitor_id first-party cookie. Spans multiple sessions across cookie rotation when identify(external_user_id) has been called. Without an external_user_id, the visitor is bounded by the cookie's lifetime (24h by default; merchants can extend to 365 days on Pro+ plans).
visitor_id in pixel_events and visitor_payments; there is no dedicated visitors table. Computing a per-visitor row is a pipe (p_visitors).visitor_id (24-char random), external_user_id (nullable, set after login), customer_email_hash (nullable, SHA-256 of lowercased email), first_seen_at, last_seen_at, sticky first_touch_utm_*.visitor_payments JOIN visitor_events on (workspace_id, visitor_id) for cohort revenue. Fallback to external_user_id when the cookie has rotated; fallback to customer_email_hash when neither is present.A bounded sequence of pageviews and goal events from one visitor. Session boundary is a 30-minute idle gap (configurable per workspace).
pixel_events via p_sessions pipe. Not stored as a discrete row in any table; computed on-read.session_id (sha256 of visitor_id + session-start timestamp), started_at, ended_at, duration_seconds, pageview_count, landing_page_url, exit_page_url, sticky session_utm_* from the landing pageview.One row per page render. The atomic unit of behavioral data.
pixel_events with event_type = 'pageview'.workspace_id, website_id, visitor_id, session_id (derived), occurred_at, page_url, page_path (normalized: trailing-slash stripped, hash stripped, query-string stripped), referrer, utm_*, device_type, country_code, region, city, language.visitor_payments on visitor_id for revenue attribution; grouped by page_path for the Pages report.Any custom goal fired via admx.goal(), data-admx-goal="", or server-side POST /api/event. Plus the seven reserved __admx_* goals fired automatically by script.plus.js (outbound click, file download, form submit, video play, scroll-50, scroll-75, scroll-90).
pixel_events with event_type = 'goal'.workspace_id, website_id, visitor_id, session_id, occurred_at, goal_name (varchar(100)), goal_metadata (JSON, max 16 keys, max 256 chars/value, max 8KB total).ad_insights_* for "goal completions per Meta ad". Drives custom funnel steps.__admx_ prefix is reserved for system-fired goals. Merchant-defined goals MUST NOT start with __.The most heavily-deduplicated entity. Each row represents exactly one charge, refund, or subscription renewal — whether it landed via Custom Pixel, server-side webhook, or daily reconciliation poll.
visitor_payments (RMT-keyed; ReplacingMergeTree).workspace_id, website_id, visitor_id, occurred_at, amount_cents (integer), currency (ISO 4217), provider (one of shopify, stripe, paddle, lemonsqueezy, polar, dodo, woocommerce), external_payment_id (provider-issued unique ID), customer_email_hash, event_kind (one of charge, refund, subscription_renewal).(workspace_id, website_id, provider, external_payment_id). The same Shopify order arriving from Custom Pixel + webhook + admin poll lands once.One row per platform-credential set the merchant has connected.
ad_platform_connections.id (UUID), workspace_id, platform (one of meta, google, tiktok), external_account_id (Meta act_*, Google customer ID, TikTok BC ID), encrypted_credentials (AES-256-GCM via server/crypto.ts), status (one of connected, expired, error, disconnected), token_expires_at.ad_sync_logs for connection-health UI; joined to ad-insights pipes via external_account_id for spend reporting.The three-level hierarchy pulled from each ad platform's API. Stored as cached insights in Tinybird, keyed by (date, account_id, campaign_id, adset_id, ad_id).
ad_insights_meta, ad_insights_google, ad_insights_tiktok. Postgres ad_sync_logs caches the most recent 15 minutes of API responses for the connection-health UI.account_id, campaign_id, adset_id, ad_id, name, status (one of active, paused, archived, deleted), spend_cents, impressions, clicks, conversions (platform-reported), occurred_on (date).ad_insights_meta to visitor_payments via attribution model.p_adlevel_* pipes.A long-lived agreement between the merchant's customer and a recurring-billing provider. Stripe subscription, Recharge subscription, ReCharge-equivalent, Polar subscription, etc.
visitor_payments with event_kind = 'subscription_renewal'.id, external_subscription_id, status (active, past_due, canceled, trialing), started_at, renewed_at, canceled_at, current_period_end.active rows by current_period_end. Churn analysis joins canceled_at by month.The relationships between the 10 entities, written as cardinality + join key:
Workspace 1 ----- N Websites (websites.workspace_id)
Workspace 1 ----- N AdAccounts (ad_platform_connections.workspace_id)
Workspace 1 ----- N WorkspaceMembers (workspace_members.workspace_id)
Workspace 1 ----- N Subscriptions (subscriptions.workspace_id)
Website 1 ------- N Visitors (pixel_events.workspace_id + website_id, then group by visitor_id)
Visitor 1 ------- N Sessions (pixel_events grouped by 30-min idle window)
Session 1 ------- N Pageviews (pixel_events with event_type = 'pageview')
Session 1 ------- N Goals (pixel_events with event_type = 'goal')
Visitor 1 ------- N Payments (visitor_payments.visitor_id)
AdAccount 1 ----- N Campaigns (ad_insights_*.account_id)
Campaign 1 ------ N AdSets (ad_insights_*.campaign_id)
AdSet 1 --------- N Ads (ad_insights_*.adset_id)
Ad N ------------ M Goals (attribution model: last-touch, linear, time-decay)
Ad N ------------ M Payments (attribution model + cohort window)
Cardinality reads "1 ----- N" as "one [left] has many [right]". N ------ M denotes a many-to-many relationship resolved at read-time by the attribution model; there is no static join table.
pixel_events — pageviews + goals (~20M rows / median DTC merchant / month)visitor_payments — revenue events, RMT-keyed for idempotencyad_insights_meta, ad_insights_google, ad_insights_tiktok — ad-platform spend + impressions + clicks + platform-conversionsklaviyo_events — email engagement (opens, clicks, conversions)gsc_* — Google Search Console impressions / clicks / positionsummary_kpis, p_summary_series — pre-aggregated summary tilesworkspaces, users, workspace_members, workspace_invites, sessions — auth + multi-tenancypixel_websites — tracked property registryad_platform_connections, ad_sync_logs — connector state + healthchat_sessions, chat_messages — Claude agent conversation statebilling_* — Stripe customer + subscription mirrorapi_keys, webhooks — programmatic access + outbound webhook configblog_posts + SEO content tables — marketing-site contentThe split rule: if it's append-only and you query it analytically, it goes to Tinybird. If it has lifecycle (created → updated → deleted) and you query it transactionally, it goes to Postgres.
The five highest-frequency joins inside Admaxxer, with the SQL shape (Tinybird flavor):
SELECT
meta.ad_id,
meta.ad_name,
SUM(pay.amount_cents) / 100 AS revenue
FROM visitor_payments pay
INNER JOIN visitor_events evt
ON pay.workspace_id = evt.workspace_id
AND pay.visitor_id = evt.visitor_id
AND evt.event_type = 'pageview'
INNER JOIN ad_insights_meta meta
ON evt.utm_source = 'meta'
AND evt.utm_content = meta.ad_id
WHERE pay.workspace_id = {workspace_id}
AND pay.occurred_at >= now() - INTERVAL 30 DAY
GROUP BY meta.ad_id, meta.ad_name
WITH first_purchase AS (
SELECT visitor_id, MIN(occurred_at) AS first_purchased_at
FROM visitor_payments
WHERE workspace_id = {workspace_id}
GROUP BY visitor_id
)
SELECT
toStartOfMonth(fp.first_purchased_at) AS cohort_month,
SUM(pay.amount_cents) / 100 AS revenue,
COUNT(DISTINCT pay.visitor_id) AS visitors,
SUM(pay.amount_cents) / 100 / COUNT(DISTINCT pay.visitor_id) AS ltv_per_visitor
FROM visitor_payments pay
INNER JOIN first_purchase fp USING (visitor_id)
WHERE pay.workspace_id = {workspace_id}
AND pay.occurred_at BETWEEN fp.first_purchased_at AND fp.first_purchased_at + INTERVAL 90 DAY
GROUP BY cohort_month
SELECT
toDate(pay.occurred_at) AS day,
SUM(pay.amount_cents) / 100 AS revenue,
(SELECT SUM(spend_cents) / 100
FROM ad_insights_meta
WHERE workspace_id = pay.workspace_id
AND occurred_on = toDate(pay.occurred_at)) AS meta_spend,
(SELECT SUM(spend_cents) / 100
FROM ad_insights_google
WHERE workspace_id = pay.workspace_id
AND occurred_on = toDate(pay.occurred_at)) AS google_spend,
revenue / (meta_spend + google_spend) AS mer
FROM visitor_payments pay
WHERE pay.workspace_id = {workspace_id}
GROUP BY day
The summary_kpis pipe pre-aggregates this; the live SQL is a fallback for ad-hoc analysis. See /documentation/data/revenue-data-flow for the full ingestion model.
snake_case. Tinybird datasources, Postgres tables and columns, Tinybird parameters in pipe SQL.camelCase. /api/v1/* JSON responses, Drizzle ORM models, React component props, Claude agent tool parameters.snake_case on both sides (GL#41). Stripe Checkout metadata keys are user_id, plan_key, ga4_client_id, utm_source, utm_medium, utm_campaign — never camelCase.external_* when the value is issued by a third-party system. Examples: external_payment_id (Stripe pi_*, Shopify GID, Paddle order ID), external_account_id (Meta act_*, Google customer ID), external_subscription_id (Stripe sub_*).__admx_ — reserved for system-fired goals (script.plus.js auto-events). Merchant-defined goals MUST NOT start with __.admx_ — the canonical prefix for website_id and other workspace-scoped slugs. Used in the DB identity check (GL#274) to distinguish Admaxxer Neon from sibling Neon projects.Different ontologies for different shapes of business:
| Entity class | Triple Whale | Datafast | Admaxxer |
|---|---|---|---|
| Multi-tenancy | Account | Workspace | Workspace |
| Tracked property | Shop (Shopify-only) | Website | Website (multi-domain) |
| Anonymous identity | (none — Shopify customer ID required) | Visitor | Visitor (visitor_id + external_user_id + email_hash) |
| Visit semantics | (implicit through orders) | Session + Pageview | Session + Pageview |
| Discrete action | Customer Journey event | Goal | Goal (custom + 7 reserved __admx_*) |
| Revenue event | Order (Shopify-native) | Payment | Payment (RMT-keyed across 7 providers) |
| Customer | Customer (with LTV, cohort, RFM) | (none as first-class entity) | (derived from Visitor + Payment) |
| Product | Product (Shopify-native catalog) | (none) | (none in v1) |
| Marketing entity | Marketing Entity (collapsed across platforms) | (none) | AdAccount + Campaign + AdSet + Ad (platform-native, three levels) |
| Operations | Operations (fulfillment, COGS) | (none) | (none in v1) |
| Subscription | Subscription (Recharge / Skio) | Subscription | Subscription (Stripe / Paddle / LS / Polar / Recharge via webhook) |
The shape difference: Triple Whale's ontology is commerce-rich (10 entities including Order, Customer, Product, Operations) because TW's roots are pure-Shopify. Datafast's ontology is analytics-only (5 entities: Visitor, Session, Pageview, Goal, Payment) because DF doesn't model the ad stack. Admaxxer sits between: 10 entities including DTC commerce + first-class ad-platform hierarchy + the analytics primitives, but without commerce-fulfillment objects (Product, Inventory, COGS) that aren't in the v1 scope.
Every entity in this document is exposed unchanged through /api/v1/*. The endpoints follow REST plus a few RPC-shaped reads for analytical queries:
GET /api/v1/workspaces/me — the current user's workspace shapeGET /api/v1/websites — tracked propertiesGET /api/v1/visitors/{visitor_id} — visitor profile + historyGET /api/v1/payments?since=...&limit=... — revenue eventsGET /api/v1/ad-accounts — connected platformsGET /api/v1/ads/{ad_id}/insights?date_range=... — spend + impressions + clicks for an adPOST /api/v1/metrics/query — arbitrary metric query (PIPE_ALLOWLIST-gated)Authentication is by API key issued from /dashboard/site-settings; rate-limited per-workspace. Quota tracking against the workspace's subscriptionPlan. The full endpoint reference is at the developer documentation hub at /documentation/developer.
Inside the dashboard, the same entities surface in the Claude agents' tool definitions (see /documentation/architecture/how-data-works): list_campaigns reads the AdAccount + Campaign + AdSet + Ad hierarchy; get_revenue_summary reads the Payment entity; query_metric exposes the same POST /api/v1/metrics/query shape.
How data works (architecture) · Revenue data flow · Tinybird auth model · Multi-currency display · Metric glossary · Conversion funnels · Custom goals · GSC integration