Privacy & data protection
GrinGo processes UK and EU personal data — this page describes what we hold, who controls what, how we protect it, and what we owe our users under UK GDPR / EU GDPR. It's engineering-focused: schema decisions, encryption, retention, and the process bits that need to be in place before we take real users.
Not legal advice. Formal compliance work — DPAs, privacy policy wording, DPO assessment — needs a lawyer.
Who controls what
| Data | Where it lives | GrinGo role | Infinia role | Notes |
|---|---|---|---|---|
Auth identifier (sub, email) | Our DB (users) | Controller | — | Apple / Google send this at first sign-in |
| Verified identity (name, DOB, address, tax_id, doc number) | Our DB (kyc_verifications.verified_identity) + Infinia | Joint controller | Controller (regulated obligation) | Both parties independently required to retain for AML |
| KYC documents & selfie | Infinia only — never on our systems | Not a controller | Controller | HOSTED widget submits directly to Infinia; special-category (biometric) data avoided from our side |
| Bank details | Our DB (user_bank_details) | Controller | Sees each on payout | Persistent copy is ours |
| Payment / transaction history | Our DB (transactions, pix_payments) | Controller | Also holds their own record | Regulatory retention applies |
| Merchant PIX data (recipient name, key) | Our DB | Controller (of merchant PII) | Also holds | Merchant is the data subject, not our user |
| Raw webhook payloads | Our DB (webhook_events.payload) | Controller | Sender | Contains third-party PII — needs explicit retention policy |
| Session JWTs | Client-only (Keychain / Keystore) | — | — | Never stored server-side |
| Platform logs | Cloudflare, Neon | Controller (via DPA) | — | Requires PII-scrubbing discipline |
Why HOSTED KYC matters here. Selfies and government IDs used for identity are Article 9 "special category" personal data under UK / EU GDPR — extra restrictions, explicit consent or a specific legal basis required. Because HOSTED mode routes documents and selfies directly to Infinia's widget, we never process them; our exposure is limited to the result of verification (name, DOB, address, doc number), which is regular personal data. If we ever moved to SELF_DECLARED KYC we'd become a controller of biometric data — much heavier compliance burden. Another reason HOSTED wins for MVP. See payment-providers.
Highest-risk stores and how we protect them
| Store | Sensitivity | Mitigation |
|---|---|---|
kyc_verifications.verified_identity (jsonb) | Names, DOB, gov ID numbers, addresses | Column-level encryption via Postgres pgcrypto, key held as a Cloudflare Worker secret; SELECT restricted to a specific service role; user-visible display always masked to the minimum needed |
user_bank_details.account_number / iban / sort_code / bic | Bank identifiers | Column-level encryption same as above; masked in UI (****1234); full value shown only during in-app edit with an explicit "reveal" tap |
webhook_events.payload (jsonb) | Raw Infinia payloads — movements, third-party bank info, KYC change details | Retention: purge older than 90 days after processed_at; consider redacting known-PII fields at write time; audit hash retained separately if needed |
Cloudflare Workers console.log output | Anything logged during a handler ends up in Cloudflare's log stream | PII-scrubbing helper — bans logging request bodies, redacts known patterns (email, IBAN, tokens); enforced by lint rule |
| Sentry / observability payloads | Error contexts often capture request state | beforeSend hook strips auth headers, redacts Zod-validated body fields, drops known-PII keys |
Column-level encryption approach. Encryption key held as a Cloudflare Worker secret (PII_ENCRYPTION_KEY), rotated on a defined schedule. pgcrypto's pgp_sym_encrypt/decrypt for the encrypted columns. Adds a small per-query perf cost — acceptable for tables that aren't in the hot path of every request.
Retention
Retention isn't optional — for financial data it's a legal requirement (UK MLR 2017, EU AMLD5: 5 years from end of relationship, extendable to 10). For non-essential data we minimise: keep only as long as the purpose requires.
| Data category | Retention rule | Trigger |
|---|---|---|
KYC records (kyc_verifications) | 6 years post account closure | users.closed_at + 6y |
| Transactions + PIX payments | 6 years post account closure | Same |
Bank details (user_bank_details) | 6 years post account closure | Same — regulator may need to trace where funds were sent |
Webhook events (webhook_events) | 90 days after processed_at; longer if related to a still-open transaction | Cron: nightly purge |
| Auth / session data | Ephemeral (JWT lifetime, 30 days) | JWT expiry — no server-side store |
users row (non-essential fields: email, display_name, home_currency) | Purge immediately on account closure | users.closed_at set |
users row (essential: id, auth_provider, auth_provider_user_id, closed_at) | 6 years post closure | Same as KYC — needed to link retained records |
| Platform / access logs | Cloudflare and Neon defaults; documented in DPA | Vendor policy |
| Backups (Neon) | 30-day PITR by default; purge follows base data | Vendor policy |
The retention cron runs nightly and:
- Purges non-essential columns on
users.closed_at + 0d. - Purges expired
webhook_eventsrows. - At
users.closed_at + 6y, hard-deletes KYC + transactions + bank details for that user, then removes theusersrow itself.
Data subject rights
Every user has (under UK / EU GDPR):
- Right of access — a copy of their data
- Right to rectification — correction of inaccurate data
- Right to erasure ("right to be forgotten") — subject to legal retention overrides
- Right to data portability — machine-readable export
- Right to restrict processing — pause certain uses of their data
- Right to object — to specific uses, e.g. marketing
- Right not to be subject to automated decision-making — relevant if we ever add automated risk scoring
MVP process: manual, handled via support@gringo.pay. Requests logged in a data_subject_requests table with SLA tracking (30 days from receipt, extendable to 90 for complex requests).
The data_subject_requests table:
| Column | Type | Notes |
|---|---|---|
id | uuid | PK |
user_id | uuid | FK → users, nullable (identity might not yet be verified) |
requester_email | text | For non-authenticated requests |
type | text | access | rectification | erasure | portability | restrict | object |
status | text | open | in_progress | completed | rejected |
received_at | timestamptz | Starts the SLA clock |
completed_at | timestamptz nullable | |
notes | text | Free-form audit trail |
Access / portability: we generate a JSON dump containing everything from users, kyc_verifications (verified identity only — not raw docs), provider_accounts, user_bank_details (unmasked), transactions, pix_payments, and webhook_events linked to the user. Delivered via a signed download URL.
Erasure: we flag the account for closure (users.closed_at), immediately purge non-essential fields, and inform the user that KYC + transactional data will be held for 6 years for regulatory reasons but excluded from any further use. After the retention window, the data is hard-deleted.
Vendor relationships and international transfers
| Vendor | Role | Data received | Location | Transfer mechanism |
|---|---|---|---|---|
| Infinia | Processor + joint controller (KYC) | Verified identity, transaction details, bank details, PIX data | TBC — need to confirm before build | Standard Contractual Clauses if outside UK/EEA |
| Cloudflare | Processor (hosting, edge) | Requests in-transit; logs | Global edge; can pin to specific regions if needed | SCCs (US-based) |
| Neon | Processor (database) | Everything in our DB | Choose an EU region | SCCs (US-based; EU data plane available) |
| Apple / Google | Controller (of OAuth identity) | Their own data; return sub and email to us | Global | Their own frameworks (Data Privacy Framework, SCCs) |
Pre-production must-haves:
- Signed DPA with Infinia, Cloudflare, Neon
- Confirmed international transfer mechanism for each processor outside UK / EEA
- Confirmed data location for Neon (pick an EU region — matches user population)
- Legitimate interest assessment or contractual basis documented for each processing purpose
Legal bases for processing
| Purpose | Legal basis | Article |
|---|---|---|
| KYC / AML checks | Legal obligation | 6(1)(c) |
| Account creation and management | Contract performance | 6(1)(b) |
| Executing payments (PIX, withdraws) | Contract performance | 6(1)(b) |
| Bank details storage for repeated use | Contract performance | 6(1)(b) |
| Fraud prevention | Legitimate interest (documented, with balancing test) | 6(1)(f) |
| Marketing communications (if / when we add any) | Consent | 6(1)(a) |
| Data breach notification to users | Legal obligation | 6(1)(c) + 34 |
Pre-production checklist
Non-negotiable before real users:
- DPA signed with Infinia
- DPA signed with Cloudflare
- DPA signed with Neon
- International transfer mechanism identified and documented for each processor outside UK / EEA
- Neon region pinned (EU)
- Privacy policy published on the marketing site, listing every processor, data categories, legal bases, retention periods, and how to exercise rights
- Cookie / tracking notice on any surface that sets non-essential cookies (marketing site probably; docs and app probably not)
-
data_subject_requeststable live and operational;support@gringo.payrunbook for handling each request type - Data breach detection + notification runbook (72-hour ICO clock, user-facing notification if high risk)
- Records of Processing Activities (GDPR Article 30) internal document
- DPO assessment: probably not required at MVP scale, but confirm with counsel
- iOS App Store privacy nutrition label matches actual data practices
- Google Play data safety section matches
Design decisions to bake in now
These are cheap during initial build and expensive to retrofit:
- Column-level encryption for
kyc_verifications.verified_identityanduser_bank_details.{account_number, sort_code, iban, bic}. data_subject_requeststable — even if the process is manual, the table gives us formal tracking and SLA measurement.users.closed_atcolumn (already in the schema) plus a retention cron that enforces the matrix above.- PII-scrubbing helper for structured logs — enforced via a lint rule so nobody accidentally logs a raw request body.
webhook_events.payloadretention — 90-day default; longer only if referenced by a still-open transaction.- Reconsider
verified_identitystorage. We store it for portability (see custody-model); trade-off is real GDPR exposure. Middle ground: store only the minimal fields we actually query (name, country), fetch the rest from Infinia on demand.
See data-model for how these land in the schema.