Data Retention Policies
How long each kind of data is actually kept on each plan, which tables age out and how a project's window is resolved, and what stays until a project is deleted.
A plan's retentionDays is not one number for all of a project's data. Each kind of data has its own window: some tables have legal or accounting floors longer than the plan's number, some are removed a whole month at a time, analytics follow their own per-plan window, and customer records are never removed by age at all. This page states what actually happens. The dashboard's Settings → Billing page shows the same table, computed from the same source (packages/shared/src/retention/effective.ts).
How long data is kept, per plan
| Data | Free | Indie | Studio | Enterprise |
|---|---|---|---|---|
| Webhook, delivery and Rovi logs; import files | 30 days | 90 days | 730 days | 1825 days |
| Audit log (in the database) | 30 days | 90 days | 730 days | 1825 days |
| MCP access log (ClickHouse) | 30 days | 90 days | 730 days | 1825 days |
| Revenue events and credit ledger (database) | at least 365 days | at least 365 days | at least 730 days | at least 1825 days |
| Revenue and credit analytics events (ClickHouse) | 365 days | 365 days | 730 days | 1825 days |
| Funnel sessions and answers | 30 days | 90 days | 730 days | 1825 days |
| Product analytics events (ClickHouse: experiment exposures, SDK sessions, funnel events) | 90 days | 365 days | 730 days | 1825 days |
| Customers, purchases and entitlements | until deleted | until deleted | until deleted | until deleted |
What the figures mean:
- Floors win. The plan's window is floored per table: the audit log and the MCP access log at 30 days (the free plan's own audit number is 7), revenue events and the credit ledger — in the database and in ClickHouse — at 365 days, product analytics at 90 days, funnel sessions and answers at 30 days. A plan, or an override, can never take a table below its floor.
- Revenue and credit figures are minimums. Those two tables are partitioned by month and the partitions are shared by every project on the instance, so they are removed a whole month at a time, and only at the longest window any project on the instance resolves. On a shared instance with a studio or enterprise project, a free project's revenue events live up to that project's window plus one month. See Partition drops are instance-wide.
- The audit log is archived, not erased. Entries past the window are moved out of the database into a proof bundle in the project's object storage, where they stay until the project is deleted. See What a checkpoint row means.
- Analytics follow the plan. The nightly sweep deletes each project's ClickHouse rows past its window (see ClickHouse tables). Product analytics have their own plan number (
analyticsDays: 90 / 365 / 730 / 1825); ClickHouse revenue and credit events followretentionDaysfloored at 365, the MCP access logauditLogDaysfloored at 30. Every stored ClickHouse table also carriesTTL <event time> + INTERVAL 1825 DAYas a backstop for the longest window. - Exposures of a running experiment are kept. An experiment that has not stopped (draft, running or paused) keeps all its exposures whatever their age; once it is completed, its old exposures go on the next nightly run.
- A checkout in progress keeps its session — for 23 hours. A funnel session whose purchase went
pendingless than 23 hours ago (Stripe expires an unpaid subscription checkout after 23 hours), and its answers, are not removed by age. An older pending purchase is an abandoned checkout and protects nothing: the session and its answers go at their window like any other. A payment that still arrives later (a one-time PaymentIntent has no Stripe expiry) is recorded from the purchase row, which is never removed by age. - Plan changes apply on the next nightly run. Moving to a smaller plan shortens every window from the next run; moving to a larger plan cannot bring back what was already removed.
- Customer records are never removed by age. Subscribers, purchases, entitlements (
subscriber_access), funnel purchases and claim tokens, and every other table without a policy below stay until the project is deleted. One customer can be erased on request: see Erasure and project deletion. - Removal is nightly. The sweep runs at 03:00 UTC, so data can outlive its window by up to a day.
- Backups. Removed data survives in backups until they expire. The
backupservice takes a dailypg_dumpand ClickHouseBACKUP, encrypted, into a private bucket; at the start of each daily run it deletes every backup that would turn 30 days old before the next run (29 days or older), so no backup is ever older than 30 days and removed data leaves the backups within 30 days. Restoring a backup re-applies every erasure and project deletion completed after it (see Erasure and project deletion). Abackup.shsnapshot taken by hand is kept until you delete it.
Which tables have a policy
The registry lives at packages/shared/src/retention/policies.ts (RETENTION_POLICIES). Each entry names a physical table, the column that ages it, a reclaim strategy, the tier field its window comes from, a floor, and — for some tables — a default used when a project has neither a plan nor an override:
| Table | Ages on | Strategy | Window from | Floor | No-plan, no-override default |
|---|---|---|---|---|---|
audit_logs | createdAt | CHECKPOINT_TRUNCATE | auditLogDays | 30 days | none — kept |
credit_ledger | createdAt | DROP_PARTITION (monthly) | retentionDays | 365 days | none — kept |
revenue_events | eventDate | DROP_PARTITION (monthly) | retentionDays | 365 days | none — kept |
outgoing_webhooks | createdAt, terminal rows only | DELETE_ROWS | retentionDays | 7 days | none — kept |
webhook_events | createdAt | DELETE_ROWS | retentionDays | 7 days | 90 days |
copilot_messages | createdAt | DELETE_ROWS | retentionDays | 7 days | 90 days |
integration_deliveries | createdAt, terminal rows only | DELETE_ROWS | retentionDays | 7 days | 30 days |
funnel_answers | answeredAt, not while the session's purchase went pending < 23 h ago; always with its session (FK cascade) | DELETE_ROWS | retentionDays | 30 days | 548 days |
funnel_sessions | lastActivityAt, not while its purchase went pending < 23 h ago | DELETE_ROWS | retentionDays | 30 days | 548 days |
import_jobs (files) | finishedAt, terminal jobs only | EXTERNAL_WORKER | retentionDays | 7 days | 7 days |
raw_revenue_events (ClickHouse) | eventDate | CLICKHOUSE_DELETE | retentionDays | 365 days | none — kept to the TTL |
raw_credit_ledger (ClickHouse) | createdAt | CLICKHOUSE_DELETE | retentionDays | 365 days | none — kept to the TTL |
raw_exposures (ClickHouse) | exposedAt, not while the experiment has not stopped | CLICKHOUSE_DELETE | analyticsDays | 90 days | none — kept to the TTL |
raw_sdk_session_events (ClickHouse) | occurredAt | CLICKHOUSE_DELETE | analyticsDays | 90 days | none — kept to the TTL |
raw_funnel_events (ClickHouse) | occurredAt | CLICKHOUSE_DELETE | analyticsDays | 90 days | none — kept to the TTL |
raw_mcp_access (ClickHouse) | occurredAt | CLICKHOUSE_DELETE | auditLogDays | 30 days | none — kept to the TTL |
outgoing_webhooks expires only terminal rows (SENT, DEAD, DISMISSED) and integration_deliveries only succeeded, skipped and dead_letter — a row still owed a delivery is never removed for being old. Partitioned DELETE_ROWS tables have their emptied partitions dropped afterwards.
The defaults exist only where a table was already removed for every project before this registry existed (a retired worker or pg_partman's own retention); the registry carries that behaviour forward and invents no new window.
How a project's window is resolved
Self-hosted instances (HOST_MODE=self) follow the enterprise windows. Plan limits do not apply to self-hosting, so every project resolves against the enterprise row of billing_tier_limits — 1825 days everywhere — whatever its own billing row says (every dashboard-created project carries a free one). An operator shortens a window with an override. On Rovenue Cloud each project follows its own plan; only a project without a billing row — one inserted outside the dashboard, or whose row was deleted — falls through to rules 2–4.
For every (project, table) pair, the sweep applies these rules, in order:
- A project with a plan and a matching
billing_tier_limitsrow resolves normally: the plan's window (retentionDays,auditLogDaysoranalyticsDays, depending on the table), clamped down by any override, floored at the table's minimum. On a self-hosted instance the plan is alwaysenterprise. - A project with no plan but an explicit override uses the override, floored only.
- A project with neither falls back to the table's default, if it has one (see the table above), floored.
- A project with neither, on a table with no default, is skipped for that table: it keeps everything. It is not defaulted to the free plan's window.
- A project whose (tier, cycle) has no
billing_tier_limitsrow — a reference-data gap — is skipped with its own reason and a warning, never treated as "no plan".
An override can only shorten a window, never lengthen it. There is no dashboard or API surface for overrides yet: an operator inserts a row into project_retention_overrides (projectId, tableName, retentionDays).
Partition drops are instance-wide
credit_ledger and revenue_events are partitioned by month, and a partition holds every project's rows for that month. A partition cannot be dropped for one project only, so:
- A partition is dropped only when every project on the instance has resolved a window for that table in this run. One project without a resolved window (rules 4 and 5) blocks the drop for everyone — those rows are then kept until it is resolved or the project is deleted.
- When every project has resolved a window, the drop uses the longest of them, and a partition goes only once its whole month is older than that window. A row therefore lives at least its own project's window, and up to the longest window on the instance plus one month.
- Rows that land in a table's
DEFAULTpartition (no monthly partition existed for their date) are never dropped by age.
Each dropped partition writes one audit row into every affected project's chain before the DROP TABLE runs, recording that project's own row count.
ClickHouse tables
The six stored ClickHouse tables are swept by the same nightly job, per project:
- Every project's window is resolved with the rules above. Projects that resolve the same window for a table are deleted together —
DELETE … WHERE projectId IN (those projects) AND <event time> < cutoff— one monthly partition at a time, so the first run on years of data is a series of partition-sized deletes rather than one table-wide one. A project with no resolved window is never in a group: its rows stay until the 1825-day TTL. - The deletes run as the delete-only eraser user. Five tables take a lightweight
DELETE FROM(the eraser holdsALTER UPDATE(_row_exists)on each of them, the row-mask column, and nothing else);raw_revenue_eventscarries a projection, which ClickHouse 24.3 refuses a lightweight delete on, so it takesALTER TABLE … DELETE IN PARTITION.verify-clickhouse-eraserchecks every statement at install/upgrade. - If ClickHouse is down or refuses a delete, that table is counted as a failure (
rovenue_retention_sweep_skipped_total{reason="error"}, a log line, the projects counted failed in the sweep's report) and nothing is recorded as done — the next night repeats the whole delete. Without ClickHouse configured at all the policies are skipped with reasonclickhouse-unconfigured. - Dashboard charts are clamped to these windows: a range longer than the project keeps (the Charts page's All, a 365-day overview range on a 90-day analytics plan) is cut to the window, and the view says so.
What a checkpoint row means
audit_logs is an append-only hash chain, so expiring it cannot be a plain delete. CHECKPOINT_TRUNCATE does this, for every project whose window has a segment old enough to reclaim:
- Export the aging segment as a proof bundle — the same assembly the
/proofdashboard endpoint uses. - Write that bundle to object storage and confirm the write.
- Only then delete the segment's rows.
- Chain a
retention.audit_checkpointedrow recording the cutoff, the number of rows deleted, the last deleted row's id and hash, and the bundle's storage key.
The bundle lives in the private import bucket (IMPORT_STORAGE_BUCKET) at audit-checkpoints/<projectId>/<checkpointId>.json. Nothing sweeps that prefix: the archived entries stay there until the project is deleted, when the purge removes the whole prefix. If object storage is not configured, CHECKPOINT_TRUNCATE fails closed and the audit rows stay in the database.
Import files
Uploaded RevenueCat/Adapty import files and their reports are removed by a separate nightly worker (workers/import-retention.ts) once the job's window has passed since it finished. It resolves its window through the same registry entry (import_jobs), deletes the objects, and stamps filesDeletedAt so a re-run never selects the job again. The import_jobs row itself stays.
Other stores
| Data | Kept for | What removes it |
|---|---|---|
| Published outbox rows | 72 hours after publishing | hourly outbox cleanup |
| DSAR export files | downloadable 30 days; the file stays until the subscriber is erased or the project is deleted | erasure / project deletion |
| Dashboard accounts, sessions and sign-in methods | until the account is deleted | account deletion |
Erasure and project deletion
Erasing a customer (dashboard Anonymize, or POST /v1/dsar/erasure) replaces their appUserId with a pseudonym, clears their attributes, deletes their rows from the ClickHouse tables keyed by subscriber, and deletes their DSAR export files. Their purchases, entitlements and credit-ledger entries stay, attached to the pseudonymous subscriber row. See DSAR.
Deleting a project removes its Postgres rows (funnel answers included — they cascade with their sessions), its ClickHouse rows and its storage objects (assets, imports, DSAR exports, audit checkpoint bundles). Its audit log entries stay, with the project link cleared, until the project's audit window (resolved at deletion; 1825 days on a self-hosted instance) has passed; the nightly sweep then removes them.
After a restore. Each completed erasure and project deletion leaves a marker in the private import bucket (deletion-markers/: the kind, the project id, the subscriber's or purge's internal id and the completion time — no app user id, attribute or email), kept for 37 days (the 30-day backup retention plus a margin). A database restore does not touch object storage, so after restoring a backup the operator runs replay-deletions, which re-applies every deletion completed since that backup was taken. See docs/operations/backup-restore.md, "Replay deletions".
The sweep itself is a nightly BullMQ repeatable job (workers/retention-sweep.ts, 03:00 UTC), safe to run on multiple API replicas — every reclaim is either scoped to the projects it names or, for a partition drop, gated on every project having resolved a window first.
Audit Proof Bundle
Export a per-project audit hash chain and verify it offline, running none of Rovenue's server code — and know exactly what that does and does not prove.
Self-Service DSAR API
Let your own backend fulfil GDPR/KVKK subject-access and erasure requests for your end users, without a support ticket to Rovenue — four secret-key endpoints, what each one actually does, and what it doesn't.