Data export (GDPR Art. 20 portability)
Use this when: a customer requests a portability export of
all their data (Article 20 GDPR / CCPA equivalent).
| Last drill | Elapsed | Next drill |
|---|---|---|
| Not yet executed | n/a | Quarterly (and every paid customer's first export) |
Two export paths
Post-ledger-pivot, the customer has two ways to get their data out:
1. CSV download (live, self-serve). Every paid tier. Click _Settings → Export & integrations → Download CSV_, or hit GET /v1/exports/csv with a valid session. One row per line item; the public schema lives at docs/csv-export.md. The endpoint is rate-limited to 10 exports per org per UTC day and records a data.exported audit event before returning the body.
2. Signed JSON portability bundle (manual at Sprint-0; self-serve MVP weeks 7–10). For full structured export including the audit log, the bundle structure described below. Until the self-serve endpoint ships, the founder runs the equivalent SQL + R2 manifest-build by hand and emails the customer a one-time download link.
Scope
A portability export contains everything we hold for the customer's org in machine-readable form. The CSV variant covers invoice records; the JSON bundle covers everything (signed):
- Account: org row, user rows, workspace rows.
- Documents: every uploaded file's metadata, including
r2_purged_at
(the timestamp at which the raw file was retention-purged from R2; see B-priv-7). Raw files for documents whose r2_purged_at IS NULL are included as the original upload bytes (PDF / image) zipped into the export; purged documents include only the metadata row.
- Extractions: every extracted record in the canonical Invoice
schema.
- Verdicts: every computed insight (price-hike, duplicate) with
payload + expected status.
- Audit log: every audit_events row (including tombstones).
- Integrations: connected providers, last sync timestamps. OAuth
tokens are NOT included (security).
Not included:
- Sub-processor-internal data (Stripe payment method handles, etc.)
— customers must request these from the sub-processor directly.
- Backups (the live data IS the export; backups are derived).
- KMS keys / DEKs.
CSV self-serve (live)
``sh curl -X GET \ -H "Authorization: Bearer <token>" \ -o ledger.csv \ https://api.muntin.digital/v1/exports/csv ``
No customer-ticket required. Audit-event recorded automatically. Filters (?start_date, ?end_date, ?vendor, ?limit) optional.
JSON portability bundle (manual at Sprint-0)
- Confirm the request is from an authorised representative
(workspace owner). Capture in customer ticket.
- Run the export script:
``sh ./scripts/export-org.sh --org-id org_xxx --output ./out/ ` _(scripts/export-org.sh` lands MVP weeks 7–10 alongside the self-serve endpoint; until then, the founder runs the equivalent SQL + R2 manifest-build by hand.)_
- The bundle structure:
`` out/ manifest.json # signed; lists everything in the bundle records/ org.json users.json workspaces.json documents.json extractions.json verdicts.json audit_events.json integrations.json files/ <document_id>/ original.pdf # or .jpg / .png / .heic ``
- Verify the manifest signature against the export-receipt key
(key-rotation.md lists where the key lives).
- Upload to a one-time-download link (signed S3 URL, 24-hour TTL,
single use).
- Notify the customer with the download link via Resend.
- Record the export in the customer's audit log:
action: "data.exported", target_kind: "org". _(Audit-event action emitted automatically by the CSV path; for the manual JSON bundle, log it by hand in the customer ticket and the table at the top of this file.)_
Verification
- Manifest signature verifies against the published receipt key.
extractions.jsonparses cleanly through the canonical Invoice
schema (muntin-schema.python tests/test_invoice.py style).
- Audit log in the export ends at the same
event_hashthat
GET /v1/audit/verify returns at the moment of export. (If they differ, the export was built mid-write and is invalid.)
- File count in
files/matchesdocumentscount in the manifest
(modulo any documents auto-deleted by retention policy).
- For CSV: import into a fresh Postgres table and confirm row
count + total sums match the source extractions.