Rovenue SDK
Guides

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.

A data-subject access request (export) or right-to-erasure request (erasure) is something your backend asks Rovenue on behalf of one of your own end users. Rovenue never authenticates that end user itself — your backend already did that, however your own auth works, before it ever calls this API.

Every route below requires your secret key (Authorization: Bearer <rov_sec_…>), never the public key. These are server-to-server calls only. A public key reaching /v1/dsar/erasure would let anyone holding your client bundle erase someone else's subscriber — the secret-key requirement is the whole reason this is safe to expose.

The four endpoints

Method & pathWhat it does
POST /v1/dsar/exportStarts (or returns the already-running) export for one subscriber.
POST /v1/dsar/erasureStarts (or returns the already-running) erasure for one subscriber.
GET /v1/dsar/:idPolls the status of a request you started.
GET /v1/dsar/:id/downloadStreams a completed export's artifact.

Starting a request

POST /v1/dsar/export
Authorization: Bearer <rov_sec_…>
Content-Type: application/json

{
  "appUserId": "rov_01h...",
  "requestedBy": "support-ticket-4821"
}

POST /v1/dsar/erasure takes the identical body shape.

appUserId accepts either identifier you hold for the subscriber: the rovenueId your SDK already has on hand (user.rovenueId from identifying users), or the app user id you passed to identify() — the one your own backend knows. The rovenueId is tried first. When that app user was later merged into another subscriber (a transfer, or an identify() on a second device), the request lands on the surviving subscriber, which is where their data now lives. A value that was never sent to Rovenue as either identifier resolves to nothing (404).

requestedBy is a free-text string your backend supplies to record who or what asked (a ticket id, an admin's email, an internal case number). Rovenue doesn't validate it against anything — it exists purely so the audit trail says who initiated the request, not just that one happened.

The subscriber is always resolved inside your own project — there is no projectId field to fill in, and none would be trusted if you sent one. The secret key itself determines which project's subscribers you can reach.

Both calls respond with the request record:

{
  "data": {
    "request": {
      "id": "dsr_01h...",
      "type": "EXPORT",
      "status": "PENDING",
      "requestedBy": "support-ticket-4821",
      "createdAt": "2026-09-06T12:00:00.000Z",
      "updatedAt": "2026-09-06T12:00:00.000Z",
      "completedAt": null,
      "expiresAt": null,
      "error": null,
      "downloadReady": false
    }
  }
}

status moves PENDING → RUNNING → COMPLETED | FAILED. The actual work runs on a background queue — this call only enqueues it and returns immediately; poll GET /v1/dsar/:id for the outcome.

Retrying is safe — it never starts a second job

If your backend calls POST /v1/dsar/export again for the same subscriber while an export is still PENDING or RUNNING, you get back the same request record, not a new one — same id, same status. No second export runs, and no second job is enqueued. This holds whether the retry is deliberate (your backend re-asking) or accidental (a timeout that makes you unsure the first call landed): it is always safe to call POST /v1/dsar/export or POST /v1/dsar/erasure again for a subscriber you're not sure already has an open request.

This retry is also what makes a request recoverable if your very FIRST call returned a 5xx instead of 200: the request record may still have been created even though the response failed. Calling the identical endpoint again finds that record and makes sure its background job actually got scheduled — creating it if the very first attempt never reached the queue — before handing back the same record. You never need a separate "did my request actually get picked up" check; the same retry you'd make anyway does that healing for you.

Once a request reaches COMPLETED or FAILED, the next call for that subscriber and type starts a genuinely new one — export is a point-in-time snapshot, not a subscription. Asking again after a completed export runs a fresh export and produces a new artifact; it does not "refresh" the old one or extend its download window.

Checking status and downloading

GET /v1/dsar/:id
Authorization: Bearer <rov_sec_…>

Returns the same request shape as above. downloadReady is true only once status is COMPLETED, an artifact exists, and it hasn't expired yet — check it before calling the download route rather than parsing status yourself.

GET /v1/dsar/:id/download
Authorization: Bearer <rov_sec_…>

There is no shareable download link. This route streams the artifact directly, authenticated by your secret key on every call — there is no signed URL, no token you can hand to someone else, nothing to copy into an email. If your own product needs to hand the export to the end user, your backend fetches it from this route and relays it onward itself.

The response is a raw stream (Content-Type: application/octet-stream) — not a JSON envelope. A request that isn't COMPLETED, has no artifact, or whose export has expired returns 404, checked before any bytes are sent.

Both GET routes 404 identically for "no such request" and "that request belongs to another project" — a probing caller can't tell the difference between an id that doesn't exist and one that belongs to somebody else.

Rate limits

POST /v1/dsar/export and POST /v1/dsar/erasure share one budget of 5 requests per minute, keyed to your API key — not 5 each. An export runs a multi-table read across a subscriber's full history, so this budget is deliberately tighter than other secret-key endpoints. GET /v1/dsar/:id and GET /v1/dsar/:id/download aren't separately throttled; they fall under the ordinary per-key envelope every /v1 call already shares (500 requests/minute).

What an export contains, and how long it lasts

An export is a point-in-time JSON snapshot of everything Rovenue holds on that subscriber — not a subscription, and not something that stays in sync with later activity. Once you have it, it reflects that moment; a subscriber's next purchase or attribute change isn't reflected until you request a new export.

The completed artifact is downloadable for 30 days from completion (expiresAt on the request record); after that, GET /v1/dsar/:id/download refuses it, but the object itself stays in storage — access is gated by the timestamp, not by removal. No scheduled sweep deletes it: the artifact stays until the subscriber it belongs to is erased (which deletes it outright, regardless of how much of the 30 days is left — see below) or the project is deleted (which purges the project's whole dsar-exports/<projectId>/ prefix).

If a subscriber is erased while their own export is still PENDING or RUNNING (queued before the erasure, or picked back up after it), that export fails instead of completing, and the request record never gains a downloadable artifact. Requesting a NEW export for a subscriber who has already been erased fails the same way, immediately, at the POST /v1/dsar/export call itself. Either way, no export can hand back data for a subject who has been erased.

One nuance, because "fails" hides it: if the erasure lands in the narrow window after the export has already read the subject's data, the artifact has been written to storage by then. Rovenue deletes it before failing the request, and the two operations take a database row lock on the subscriber so they cannot interleave — whichever finishes first, the other sees it. The residual case is a storage error during that delete: the request still fails and the artifact is unreachable through this API, but the object itself can survive in the bucket, and no sweep will remove it.

What erasure actually does

Erasure anonymises the subscriber row — it does not delete it. The row's own id survives, its appUserId is replaced with a deterministic, non-reversible token, its attributes are cleared, and it's stamped deletedAt. Keeping the row (rather than deleting it outright) is what lets the append-only credit_ledger keep its guarantee: every ledger entry that ever referenced this subscriber still resolves to a real row, just one that no longer identifies anyone.

Erasure also deletes every artifact from any of the subscriber's OWN prior exports — every dsar-exports/... object this subscriber's completed POST /v1/dsar/export calls ever produced, not just the most recent one. Postgres anonymisation alone would not be enough: it never touches purchases, entitlements, or the credit ledger, and an already-downloaded export snapshot still has all of that under the subscriber's real, pre-erasure identity. After erasure, GET /v1/dsar/:id/download for any of that subscriber's own past export requests returns 404 — the request record itself still shows COMPLETED (the export genuinely did complete, at the time), but it no longer has anything to serve.

Erasure also purges the subscriber's rows from every ClickHouse table that stores a plain, queryable subscriber id at rest:

  • raw_exposures
  • raw_revenue_events
  • raw_credit_ledger
  • raw_sdk_session_events
  • raw_funnel_events

A request is only marked COMPLETED once every part is done — the Postgres anonymisation, the export-artifact deletion above, and every one of the five ClickHouse deletes confirmed finished (not merely submitted; ClickHouse deletes run asynchronously in the background, and this worker waits for each one to actually complete before reporting success). If any part fails, the request is marked FAILED, never COMPLETED — a record that claims erasure happened while rows (or an old export) are still sitting there would be worse than no record at all. Retrying a failed erasure request is safe: re-running the Postgres step on an already-anonymised subscriber is a no-op, re-deleting an artifact that's already gone matches nothing, and re-submitting the ClickHouse deletes against already-purged rows matches nothing either.

Erasure reaches everything Rovenue stores. It cannot reach a copy of that subscriber's data your own backend already exported and kept — from a prior GET /v1/dsar/:id/download, your own database, your logs, or anywhere else outside Rovenue. Fulfilling erasure for copies you hold yourself is your responsibility.

Limits worth stating plainly

  • Only an erasure of the subscriber the export belongs to deletes the artifact from storage. The 30-day expiresAt gate is a download-access cutoff, not a deletion trigger: once it passes, GET /v1/dsar/:id/download refuses the request, but nothing sweeps the object out of the bucket on that schedule — or on any schedule. An artifact whose 30 days haven't passed yet, for a subscriber who is never erased, keeps physically existing in storage indefinitely; the same is true, indefinitely, once the 30 days HAVE passed. This isn't something a separate retention job happens to cover either — the file-retention sweeper that runs against uploaded import files only ever deletes storage keys it reads off import_jobs rows, never a dsar_requests one, so the dsar-exports/ object is outside its scope entirely. Short of erasing the subject or deleting the project, an export artifact simply stays in storage.
  • Erased subscribers staying erased is enforced by each write route individually (every route that can write to a subscriber checks whether it's soft-deleted before touching it, so an end user's next SDK call can't silently resurrect a forgotten identity). A build-time check now fails if a route reaches a subscriber without consulting that signal, so a newly added write route can't quietly skip it — though the check proves a route consults the signal, not that it acts on it correctly, so per-route behaviour still carries its own tests.
  • This is a read-side guarantee too, not only a write-side one: a POST /v1/dsar/export in flight when the same subscriber is erased fails rather than completing, and never leaves a downloadable artifact behind — see the callout above for the one storage-error case where the object itself can outlive the request.

Building this into your own flow

A typical integration: your backend receives a subject-access or erasure request through whatever channel you already use (a support form, an account-settings page, a compliance inbox), authenticates that it's really from the account holder, then calls the matching endpoint here with that subscriber's rovenueId and something identifying the request internally. Poll GET /v1/dsar/:id until status leaves RUNNING, and for exports, fetch and relay the artifact via GET /v1/dsar/:id/download before its 30-day window closes.

On this page