Skip to main content

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

DataWhere it livesGrinGo roleInfinia roleNotes
Auth identifier (sub, email)Our DB (users)ControllerApple / Google send this at first sign-in
Verified identity (name, DOB, address, tax_id, doc number)Our DB (kyc_verifications.verified_identity) + InfiniaJoint controllerController (regulated obligation)Both parties independently required to retain for AML
KYC documents & selfieInfinia only — never on our systemsNot a controllerControllerHOSTED widget submits directly to Infinia; special-category (biometric) data avoided from our side
Bank detailsOur DB (user_bank_details)ControllerSees each on payoutPersistent copy is ours
Payment / transaction historyOur DB (transactions, pix_payments)ControllerAlso holds their own recordRegulatory retention applies
Merchant PIX data (recipient name, key)Our DBController (of merchant PII)Also holdsMerchant is the data subject, not our user
Raw webhook payloadsOur DB (webhook_events.payload)ControllerSenderContains third-party PII — needs explicit retention policy
Session JWTsClient-only (Keychain / Keystore)Never stored server-side
Platform logsCloudflare, NeonController (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

StoreSensitivityMitigation
kyc_verifications.verified_identity (jsonb)Names, DOB, gov ID numbers, addressesColumn-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 / bicBank identifiersColumn-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 detailsRetention: 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 outputAnything logged during a handler ends up in Cloudflare's log streamPII-scrubbing helper — bans logging request bodies, redacts known patterns (email, IBAN, tokens); enforced by lint rule
Sentry / observability payloadsError contexts often capture request statebeforeSend 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 categoryRetention ruleTrigger
KYC records (kyc_verifications)6 years post account closureusers.closed_at + 6y
Transactions + PIX payments6 years post account closureSame
Bank details (user_bank_details)6 years post account closureSame — 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 transactionCron: nightly purge
Auth / session dataEphemeral (JWT lifetime, 30 days)JWT expiry — no server-side store
users row (non-essential fields: email, display_name, home_currency)Purge immediately on account closureusers.closed_at set
users row (essential: id, auth_provider, auth_provider_user_id, closed_at)6 years post closureSame as KYC — needed to link retained records
Platform / access logsCloudflare and Neon defaults; documented in DPAVendor policy
Backups (Neon)30-day PITR by default; purge follows base dataVendor policy

The retention cron runs nightly and:

  1. Purges non-essential columns on users.closed_at + 0d.
  2. Purges expired webhook_events rows.
  3. At users.closed_at + 6y, hard-deletes KYC + transactions + bank details for that user, then removes the users row 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:

ColumnTypeNotes
iduuidPK
user_iduuidFK → users, nullable (identity might not yet be verified)
requester_emailtextFor non-authenticated requests
typetextaccess | rectification | erasure | portability | restrict | object
statustextopen | in_progress | completed | rejected
received_attimestamptzStarts the SLA clock
completed_attimestamptz nullable
notestextFree-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

VendorRoleData receivedLocationTransfer mechanism
InfiniaProcessor + joint controller (KYC)Verified identity, transaction details, bank details, PIX dataTBC — need to confirm before buildStandard Contractual Clauses if outside UK/EEA
CloudflareProcessor (hosting, edge)Requests in-transit; logsGlobal edge; can pin to specific regions if neededSCCs (US-based)
NeonProcessor (database)Everything in our DBChoose an EU regionSCCs (US-based; EU data plane available)
Apple / GoogleController (of OAuth identity)Their own data; return sub and email to usGlobalTheir 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
PurposeLegal basisArticle
KYC / AML checksLegal obligation6(1)(c)
Account creation and managementContract performance6(1)(b)
Executing payments (PIX, withdraws)Contract performance6(1)(b)
Bank details storage for repeated useContract performance6(1)(b)
Fraud preventionLegitimate interest (documented, with balancing test)6(1)(f)
Marketing communications (if / when we add any)Consent6(1)(a)
Data breach notification to usersLegal obligation6(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_requests table live and operational; support@gringo.pay runbook 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_identity and user_bank_details.{account_number, sort_code, iban, bic}.
  • data_subject_requests table — even if the process is manual, the table gives us formal tracking and SLA measurement.
  • users.closed_at column (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.payload retention — 90-day default; longer only if referenced by a still-open transaction.
  • Reconsider verified_identity storage. 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.