Charts
RevenueDot has 42 charts in the dashboard under Analytics > Charts, with the same names, groups and definitions as RevenueCat's Charts v3 wherever RevenueDot holds the same data. The REST API serves the same numbers at GET /v2/projects/{project_id}/charts/{chart_name} with RevenueCat's parameters and response shape, so scripts written for RevenueCat's charts API keep working.
Rules every chart follows#
- Sandbox purchases are excluded. The Sandbox data switch (API:
environment=sandbox) shows only sandbox and Test Store purchases instead. Customer counts do not depend on the switch. - Granted access and Family Sharing are excluded from every money and subscription number.
- Money is in USD at the exchange rate of the purchase date. Another currency (
currency=EURand 13 others) converts that USD amount at the same date's rate, so a subscription's MRR keeps its purchase-date rate. - Periods are UTC. Days start at 00:00 UTC and weeks on Monday.
- Stock numbers are snapshots at the end of each period (active subscriptions, active trials, MRR, ARR). The current period's snapshot is taken now.
- Refunds count on the refund date, not the purchase date.
- A resubscription is a new subscription. A customer who comes back after their subscription lapsed starts a new one; a renewal after a billing issue (billing recovery) continues the old one.
- A product change ends one subscription and starts another at the moment of the change.
- Incomplete periods (the current one, a partial first period, cohorts whose window is still open) are marked with
*in the dashboard andincomplete: truein the API.
How each chart is calculated#
Revenue#
Subscriptions#
Ads#
LTV#
Customers#
Conversion#
Paywalls#
Trials#
Churn and refunds#
Retention#
Filters and segments#
Filter and segment by app, store, product, product duration, offering, country (the purchase's storefront, else the customer's last country), platform and app version; paywall charts also by paywall, and the Customer Center chart by survey option. A filter on a purchase dimension (store, product …) does not change the new-customer counts that conversion charts divide by. A segmented chart shows the five largest values, then "Other" and the total.
Use the API#
curl -H "Authorization: Bearer $REVENUEDOT_SECRET_KEY" \
"https://api.revenuedot.app/v2/projects/$PROJECT_ID/charts/mrr?resolution=month&start_date=2026-01-01&end_date=2026-06-30"resolution:0–4orday,week,month,quarter,year.filters:[{"name":"store","values":["app_store"]}];segmentandlimit_num_segments;selectors:{"revenue_type":"proceeds"}.GET .../charts/{chart_name}/optionslists the resolutions, segments, filters with the values in your data, and the selectors.- Time series return
valuesas{cohort, measure, value, incomplete}withcohortthe period start in Unix seconds; cohort tables return{cohort, period, value}withperiods[0]the cohort size. Reference: Charts API.
The key needs the charts_metrics:charts:read permission.
SQL for the core charts#
RevenueDot computes charts from its own tables. These PostgreSQL queries reproduce the core charts from the same tables, and RevenueDot's tests check that they return the API's numbers. Run one against your database with psql variables (end_date is exclusive):
psql "$DATABASE_URL" -v project_id=proj_123 -v resolution=month -v start_date=2026-01-01 \
-v end_date=2026-07-01 -v now="$(date -u +%FT%TZ)" -f mrr.sqlRevenue and transactions query#
-- Revenue: purchases, renewals and one-time purchases, minus refunds on the refund date, plus ad revenue reported in USD.
-- Transactions: paid purchases, renewals and one-time purchases (refunds do not reduce it).
WITH periods AS (
SELECT p AS period, LEAST(p + ('1 ' || :'resolution')::interval, :'end_date'::timestamptz AT TIME ZONE 'UTC', :'now'::timestamptz AT TIME ZONE 'UTC') AS period_end
FROM generate_series(date_trunc(:'resolution', :'start_date'::timestamptz AT TIME ZONE 'UTC'),
(:'end_date'::timestamptz AT TIME ZONE 'UTC') - interval '1 microsecond',
('1 ' || :'resolution')::interval) AS p
),
ledger AS (
SELECT t.*, t.purchased_at AT TIME ZONE 'UTC' AS at
FROM transactions t
WHERE t.project_id = :'project_id' AND NOT t.is_sandbox AND t.store <> 'promotional'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.customer_id AND s.store = t.store
AND s.product_identifier = t.product_identifier AND s.ownership_type = 'FAMILY_SHARED')
),
money AS (
SELECT at, revenue_usd AS usd, kind IN ('purchase', 'renewal', 'one_time') AS is_tx FROM ledger WHERE kind <> 'trial'
UNION ALL
SELECT occurred_at AT TIME ZONE 'UTC', (payload->>'revenue_micros')::numeric / 1000000, false FROM sdk_events
WHERE project_id = :'project_id' AND NOT is_sandbox AND type = 'rc_ads_ad_revenue' AND coalesce(payload->>'currency', 'USD') = 'USD'
)
SELECT pe.period, round(coalesce(sum(m.usd), 0)::numeric, 2) AS revenue, count(m.*) FILTER (WHERE m.is_tx) AS transactions
FROM periods pe
LEFT JOIN money m ON m.at >= GREATEST(pe.period, :'start_date'::timestamptz AT TIME ZONE 'UTC') AND m.at < pe.period_end
GROUP BY pe.period ORDER BY pe.period;Non-subscription purchases query#
-- One-time purchases (consumables, non-consumables, lifetime) per period.
WITH periods AS (
SELECT p AS period, LEAST(p + ('1 ' || :'resolution')::interval, :'end_date'::timestamptz AT TIME ZONE 'UTC', :'now'::timestamptz AT TIME ZONE 'UTC') AS period_end
FROM generate_series(date_trunc(:'resolution', :'start_date'::timestamptz AT TIME ZONE 'UTC'),
(:'end_date'::timestamptz AT TIME ZONE 'UTC') - interval '1 microsecond',
('1 ' || :'resolution')::interval) AS p
),
ledger AS (
SELECT t.*, t.purchased_at AT TIME ZONE 'UTC' AS at
FROM transactions t
WHERE t.project_id = :'project_id' AND NOT t.is_sandbox AND t.store <> 'promotional'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.customer_id AND s.store = t.store
AND s.product_identifier = t.product_identifier AND s.ownership_type = 'FAMILY_SHARED')
)
SELECT pe.period, count(l.*) AS purchases
FROM periods pe
LEFT JOIN ledger l ON l.kind = 'one_time' AND l.at >= GREATEST(pe.period, :'start_date'::timestamptz AT TIME ZONE 'UTC') AND l.at < pe.period_end
GROUP BY pe.period ORDER BY pe.period;Refunds query#
-- Money refunded and refunded transactions by refund date, net of reversed refunds.
WITH periods AS (
SELECT p AS period, LEAST(p + ('1 ' || :'resolution')::interval, :'end_date'::timestamptz AT TIME ZONE 'UTC', :'now'::timestamptz AT TIME ZONE 'UTC') AS period_end
FROM generate_series(date_trunc(:'resolution', :'start_date'::timestamptz AT TIME ZONE 'UTC'),
(:'end_date'::timestamptz AT TIME ZONE 'UTC') - interval '1 microsecond',
('1 ' || :'resolution')::interval) AS p
),
ledger AS (
SELECT t.*, t.purchased_at AT TIME ZONE 'UTC' AS at
FROM transactions t
WHERE t.project_id = :'project_id' AND NOT t.is_sandbox AND t.store <> 'promotional'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.customer_id AND s.store = t.store
AND s.product_identifier = t.product_identifier AND s.ownership_type = 'FAMILY_SHARED')
)
SELECT pe.period,
round(coalesce(-sum(l.revenue_usd), 0)::numeric, 2) AS refunded_revenue,
count(l.*) FILTER (WHERE l.kind = 'refund') - count(l.*) FILTER (WHERE l.kind = 'refund_reversal') AS refunded_transactions
FROM periods pe
LEFT JOIN ledger l ON l.kind IN ('refund', 'refund_reversal') AND l.at >= GREATEST(pe.period, :'start_date'::timestamptz AT TIME ZONE 'UTC') AND l.at < pe.period_end
GROUP BY pe.period ORDER BY pe.period;New trials query#
-- Free trials started per period.
WITH periods AS (
SELECT p AS period, LEAST(p + ('1 ' || :'resolution')::interval, :'end_date'::timestamptz AT TIME ZONE 'UTC', :'now'::timestamptz AT TIME ZONE 'UTC') AS period_end
FROM generate_series(date_trunc(:'resolution', :'start_date'::timestamptz AT TIME ZONE 'UTC'),
(:'end_date'::timestamptz AT TIME ZONE 'UTC') - interval '1 microsecond',
('1 ' || :'resolution')::interval) AS p
),
ledger AS (
SELECT t.*, t.purchased_at AT TIME ZONE 'UTC' AS at
FROM transactions t
WHERE t.project_id = :'project_id' AND NOT t.is_sandbox AND t.store <> 'promotional'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.customer_id AND s.store = t.store
AND s.product_identifier = t.product_identifier AND s.ownership_type = 'FAMILY_SHARED')
)
SELECT pe.period, count(l.*) AS new_trials
FROM periods pe
LEFT JOIN ledger l ON l.kind = 'trial' AND l.at >= GREATEST(pe.period, :'start_date'::timestamptz AT TIME ZONE 'UTC') AND l.at < pe.period_end
GROUP BY pe.period ORDER BY pe.period;New customers query#
-- Customers whose cohort date (the earlier of first seen and first purchase) falls in the period.
WITH periods AS (
SELECT p AS period, LEAST(p + ('1 ' || :'resolution')::interval, :'end_date'::timestamptz AT TIME ZONE 'UTC', :'now'::timestamptz AT TIME ZONE 'UTC') AS period_end
FROM generate_series(date_trunc(:'resolution', :'start_date'::timestamptz AT TIME ZONE 'UTC'),
(:'end_date'::timestamptz AT TIME ZONE 'UTC') - interval '1 microsecond',
('1 ' || :'resolution')::interval) AS p
),
ledger AS (
SELECT t.*, t.purchased_at AT TIME ZONE 'UTC' AS at
FROM transactions t
WHERE t.project_id = :'project_id' AND NOT t.is_sandbox AND t.store <> 'promotional'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.customer_id AND s.store = t.store
AND s.product_identifier = t.product_identifier AND s.ownership_type = 'FAMILY_SHARED')
),
cohorts AS (
SELECT c.id, LEAST(c.first_seen, (SELECT min(purchased_at) FROM ledger l WHERE l.customer_id = c.id)) AT TIME ZONE 'UTC' AS cohort_at
FROM customers c WHERE c.project_id = :'project_id'
)
SELECT pe.period, count(c.*) AS new_customers
FROM periods pe
LEFT JOIN cohorts c ON c.cohort_at >= GREATEST(pe.period, :'start_date'::timestamptz AT TIME ZONE 'UTC') AND c.cohort_at < pe.period_end
GROUP BY pe.period ORDER BY pe.period;Active subscriptions query#
-- Paid subscriptions with access at the end of each period (cancelled ones count until they expire).
WITH periods AS (
SELECT p AS period, LEAST(p + ('1 ' || :'resolution')::interval, :'end_date'::timestamptz AT TIME ZONE 'UTC', :'now'::timestamptz AT TIME ZONE 'UTC') AS period_end
FROM generate_series(date_trunc(:'resolution', :'start_date'::timestamptz AT TIME ZONE 'UTC'),
(:'end_date'::timestamptz AT TIME ZONE 'UTC') - interval '1 microsecond',
('1 ' || :'resolution')::interval) AS p
),
ledger AS (
SELECT t.*, t.purchased_at AT TIME ZONE 'UTC' AS at
FROM transactions t
WHERE t.project_id = :'project_id' AND NOT t.is_sandbox AND t.store <> 'promotional'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.customer_id AND s.store = t.store
AND s.product_identifier = t.product_identifier AND s.ownership_type = 'FAMILY_SHARED')
),
refunds AS (
SELECT store, store_transaction_id, max(purchased_at) AS refunded_at
FROM ledger WHERE kind IN ('refund', 'refund_reversal')
GROUP BY store, store_transaction_id
HAVING count(*) FILTER (WHERE kind = 'refund') > count(*) FILTER (WHERE kind = 'refund_reversal')
),
sub_periods AS (
SELECT l.customer_id, l.store, coalesce(l.app_id, '') AS app_id, l.product_identifier, l.kind, l.purchased_at AS starts_at,
LEAST(GREATEST(coalesce(l.expires_at, 'infinity'),
CASE WHEN s.billing_issues_detected_at IS NOT NULL AND l.expires_at >= s.expires_date - interval '1 hour'
THEN s.grace_period_expires_date END),
coalesce(r.refunded_at, 'infinity')) AS ends_at,
l.revenue_usd,
coalesce(
CASE
WHEN p.duration ~ '^P\d+Y$' THEN 1.0 / (12 * substring(p.duration from '\d+')::numeric)
WHEN p.duration ~ '^P\d+M$' THEN 1.0 / substring(p.duration from '\d+')::numeric
WHEN p.duration ~ '^P\d+W$' THEN 4.0 / substring(p.duration from '\d+')::numeric
WHEN p.duration ~ '^P\d+D$' THEN 30.0 / substring(p.duration from '\d+')::numeric
END,
30.0 / GREATEST(1, extract(epoch FROM l.expires_at - l.purchased_at) / 86400)) AS monthly_factor
FROM ledger l
LEFT JOIN refunds r ON r.store = l.store AND r.store_transaction_id = l.store_transaction_id
LEFT JOIN subscriptions s ON s.customer_id = l.customer_id AND s.store = l.store AND s.product_identifier = l.product_identifier
LEFT JOIN LATERAL (
SELECT duration FROM products p WHERE p.project_id = l.project_id
AND (p.store_identifier = l.product_identifier OR split_part(p.store_identifier, ':', 1) = l.product_identifier)
ORDER BY (p.app_id = l.app_id) DESC, (p.store_identifier = l.product_identifier) DESC LIMIT 1
) p ON true
WHERE l.kind IN ('trial', 'purchase', 'renewal')
),
snapshot AS (
SELECT DISTINCT ON (pe.period, sp.customer_id, sp.store, sp.app_id) pe.period, sp.kind, sp.revenue_usd * sp.monthly_factor AS mrr
FROM periods pe
JOIN sub_periods sp ON sp.starts_at <= (pe.period_end - interval '1 millisecond') AT TIME ZONE 'UTC'
AND sp.ends_at > (pe.period_end - interval '1 millisecond') AT TIME ZONE 'UTC'
ORDER BY pe.period, sp.customer_id, sp.store, sp.app_id, sp.starts_at DESC
)
SELECT pe.period, count(s.*) FILTER (WHERE s.kind <> 'trial') AS actives
FROM periods pe LEFT JOIN snapshot s ON s.period = pe.period
GROUP BY pe.period ORDER BY pe.period;Active trials query#
-- Free trials with access at the end of each period.
WITH periods AS (
SELECT p AS period, LEAST(p + ('1 ' || :'resolution')::interval, :'end_date'::timestamptz AT TIME ZONE 'UTC', :'now'::timestamptz AT TIME ZONE 'UTC') AS period_end
FROM generate_series(date_trunc(:'resolution', :'start_date'::timestamptz AT TIME ZONE 'UTC'),
(:'end_date'::timestamptz AT TIME ZONE 'UTC') - interval '1 microsecond',
('1 ' || :'resolution')::interval) AS p
),
ledger AS (
SELECT t.*, t.purchased_at AT TIME ZONE 'UTC' AS at
FROM transactions t
WHERE t.project_id = :'project_id' AND NOT t.is_sandbox AND t.store <> 'promotional'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.customer_id AND s.store = t.store
AND s.product_identifier = t.product_identifier AND s.ownership_type = 'FAMILY_SHARED')
),
refunds AS (
SELECT store, store_transaction_id, max(purchased_at) AS refunded_at
FROM ledger WHERE kind IN ('refund', 'refund_reversal')
GROUP BY store, store_transaction_id
HAVING count(*) FILTER (WHERE kind = 'refund') > count(*) FILTER (WHERE kind = 'refund_reversal')
),
sub_periods AS (
SELECT l.customer_id, l.store, coalesce(l.app_id, '') AS app_id, l.product_identifier, l.kind, l.purchased_at AS starts_at,
LEAST(GREATEST(coalesce(l.expires_at, 'infinity'),
CASE WHEN s.billing_issues_detected_at IS NOT NULL AND l.expires_at >= s.expires_date - interval '1 hour'
THEN s.grace_period_expires_date END),
coalesce(r.refunded_at, 'infinity')) AS ends_at,
l.revenue_usd,
coalesce(
CASE
WHEN p.duration ~ '^P\d+Y$' THEN 1.0 / (12 * substring(p.duration from '\d+')::numeric)
WHEN p.duration ~ '^P\d+M$' THEN 1.0 / substring(p.duration from '\d+')::numeric
WHEN p.duration ~ '^P\d+W$' THEN 4.0 / substring(p.duration from '\d+')::numeric
WHEN p.duration ~ '^P\d+D$' THEN 30.0 / substring(p.duration from '\d+')::numeric
END,
30.0 / GREATEST(1, extract(epoch FROM l.expires_at - l.purchased_at) / 86400)) AS monthly_factor
FROM ledger l
LEFT JOIN refunds r ON r.store = l.store AND r.store_transaction_id = l.store_transaction_id
LEFT JOIN subscriptions s ON s.customer_id = l.customer_id AND s.store = l.store AND s.product_identifier = l.product_identifier
LEFT JOIN LATERAL (
SELECT duration FROM products p WHERE p.project_id = l.project_id
AND (p.store_identifier = l.product_identifier OR split_part(p.store_identifier, ':', 1) = l.product_identifier)
ORDER BY (p.app_id = l.app_id) DESC, (p.store_identifier = l.product_identifier) DESC LIMIT 1
) p ON true
WHERE l.kind IN ('trial', 'purchase', 'renewal')
),
snapshot AS (
SELECT DISTINCT ON (pe.period, sp.customer_id, sp.store, sp.app_id) pe.period, sp.kind, sp.revenue_usd * sp.monthly_factor AS mrr
FROM periods pe
JOIN sub_periods sp ON sp.starts_at <= (pe.period_end - interval '1 millisecond') AT TIME ZONE 'UTC'
AND sp.ends_at > (pe.period_end - interval '1 millisecond') AT TIME ZONE 'UTC'
ORDER BY pe.period, sp.customer_id, sp.store, sp.app_id, sp.starts_at DESC
)
SELECT pe.period, count(s.*) FILTER (WHERE s.kind = 'trial') AS trials
FROM periods pe LEFT JOIN snapshot s ON s.period = pe.period
GROUP BY pe.period ORDER BY pe.period;MRR query#
-- Monthly recurring revenue at the end of each period: each active paid subscription's USD price times its
-- duration's factor (1 day ×30, 1 week ×4, 1 month ×1, 3 months ×1/3, 1 year ×1/12 …).
WITH periods AS (
SELECT p AS period, LEAST(p + ('1 ' || :'resolution')::interval, :'end_date'::timestamptz AT TIME ZONE 'UTC', :'now'::timestamptz AT TIME ZONE 'UTC') AS period_end
FROM generate_series(date_trunc(:'resolution', :'start_date'::timestamptz AT TIME ZONE 'UTC'),
(:'end_date'::timestamptz AT TIME ZONE 'UTC') - interval '1 microsecond',
('1 ' || :'resolution')::interval) AS p
),
ledger AS (
SELECT t.*, t.purchased_at AT TIME ZONE 'UTC' AS at
FROM transactions t
WHERE t.project_id = :'project_id' AND NOT t.is_sandbox AND t.store <> 'promotional'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.customer_id AND s.store = t.store
AND s.product_identifier = t.product_identifier AND s.ownership_type = 'FAMILY_SHARED')
),
refunds AS (
SELECT store, store_transaction_id, max(purchased_at) AS refunded_at
FROM ledger WHERE kind IN ('refund', 'refund_reversal')
GROUP BY store, store_transaction_id
HAVING count(*) FILTER (WHERE kind = 'refund') > count(*) FILTER (WHERE kind = 'refund_reversal')
),
sub_periods AS (
SELECT l.customer_id, l.store, coalesce(l.app_id, '') AS app_id, l.product_identifier, l.kind, l.purchased_at AS starts_at,
LEAST(GREATEST(coalesce(l.expires_at, 'infinity'),
CASE WHEN s.billing_issues_detected_at IS NOT NULL AND l.expires_at >= s.expires_date - interval '1 hour'
THEN s.grace_period_expires_date END),
coalesce(r.refunded_at, 'infinity')) AS ends_at,
l.revenue_usd,
coalesce(
CASE
WHEN p.duration ~ '^P\d+Y$' THEN 1.0 / (12 * substring(p.duration from '\d+')::numeric)
WHEN p.duration ~ '^P\d+M$' THEN 1.0 / substring(p.duration from '\d+')::numeric
WHEN p.duration ~ '^P\d+W$' THEN 4.0 / substring(p.duration from '\d+')::numeric
WHEN p.duration ~ '^P\d+D$' THEN 30.0 / substring(p.duration from '\d+')::numeric
END,
30.0 / GREATEST(1, extract(epoch FROM l.expires_at - l.purchased_at) / 86400)) AS monthly_factor
FROM ledger l
LEFT JOIN refunds r ON r.store = l.store AND r.store_transaction_id = l.store_transaction_id
LEFT JOIN subscriptions s ON s.customer_id = l.customer_id AND s.store = l.store AND s.product_identifier = l.product_identifier
LEFT JOIN LATERAL (
SELECT duration FROM products p WHERE p.project_id = l.project_id
AND (p.store_identifier = l.product_identifier OR split_part(p.store_identifier, ':', 1) = l.product_identifier)
ORDER BY (p.app_id = l.app_id) DESC, (p.store_identifier = l.product_identifier) DESC LIMIT 1
) p ON true
WHERE l.kind IN ('trial', 'purchase', 'renewal')
),
snapshot AS (
SELECT DISTINCT ON (pe.period, sp.customer_id, sp.store, sp.app_id) pe.period, sp.kind, sp.revenue_usd * sp.monthly_factor AS mrr
FROM periods pe
JOIN sub_periods sp ON sp.starts_at <= (pe.period_end - interval '1 millisecond') AT TIME ZONE 'UTC'
AND sp.ends_at > (pe.period_end - interval '1 millisecond') AT TIME ZONE 'UTC'
ORDER BY pe.period, sp.customer_id, sp.store, sp.app_id, sp.starts_at DESC
)
SELECT pe.period, round(coalesce(sum(s.mrr) FILTER (WHERE s.kind <> 'trial'), 0)::numeric, 2) AS mrr
FROM periods pe LEFT JOIN snapshot s ON s.period = pe.period
GROUP BY pe.period ORDER BY pe.period;Differences from RevenueCat#
- Taxes: the stores do not report tax per purchase, so "revenue net of taxes" equals revenue and proceeds subtract only the store commission. RevenueCat estimates tax per country (Taxes and commissions).
- Exchange rates: RevenueDot uses the ECB's daily rates, so converted amounts can differ by a few cents.
- Paid introductory offers are counted as direct purchases in Paid Subscriptions.
- Dimensions RevenueCat also offers (renewal cycle, offer type, first purchase month, attribution, custom attributes) are not available yet; platform and app version are the customer's latest, not their first.
- Prediction Explorer projects from your own cohorts, not from a model trained on many apps.
- App Store Save Outcomes is always zero, and refund requests cover the App Store only.
- Active Customers counts days of SDK activity from the update that added it; earlier days only know each customer's first and last visit.