Skip to main content

Onboarding & KYC

First launch: sign in with native Apple or Google OAuth, choose home currency, complete Infinia HOSTED KYC. All money-moving features gate on kyc.status === "completed".

Sequence

Payload detail for the Infinia calls:

  • POST /v1/accounts/owners{ type: "INDIVIDUAL", kyc_mode: "HOSTED" }
  • POST /v1/accounts{ owner_id, country: "GB", currency: "GBP", products: ["PAYINS", "PAYOUTS", "INTERNAL_TRANSFER"] } (EUR variant uses country: "EU", currency: "EUR")
  • Owner status poll → GET /v1/accounts/owners/{uuid}/

Screens

  • apps/mobile/app/login.tsx — Apple + Google buttons; single tap flow.
  • apps/mobile/app/onboarding/home-currency.tsx — GBP / EUR picker, one-time choice.
  • apps/mobile/app/onboarding/kyc.tsx — kicks off KYC start, opens the Infinia widget, polls status.
  • apps/mobile/app/_layout.tsxAuthGate chooses which screen to show based on sessionToken → user.home_currency → kyc.status → account.status state.

What each side owns

ConcernOwner
Auth sessionGrinGo (session JWT we sign, verified per-request)
OAuth identity verificationApple / Google (their JWKS, our JWT verification)
KYC documents & selfiesInfinia (via SumSub under the hood — the HOSTED widget)
Verified identity storageInfinia + GrinGo (we store a copy on kyc_verifications for portability)
Virtual account provisioningInfinia (with our POST /v1/accounts orchestration)
Funding instructions displayGrinGo mobile (from provider_accounts.funding_instructions)

Country allowlist (MVP)

MVP is restricted to UK + EU-27 senders. Enforcement is a post-KYC check: after Infinia's HOSTED widget completes and returns verified_identity.individual.address.country, we compare against the allowlist:

const MVP_COUNTRY_ALLOWLIST = [
"GB",
// EU-27:
"AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE",
"IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE",
];

Flow when a user's verified country is not on the allowlist:

  1. kyc_verifications.status is set to country_not_supported (a terminal state distinct from failed).
  2. No provider_accounts row is provisioned — the user has no way to deposit or spend.
  3. Mobile shows a "not yet available in your country — join the waitlist" screen with an email capture that writes to a waitlist table (user_id, country, email).
  4. The Infinia account owner record is retained for audit but marked country_not_supported; no further Infinia calls are made against it.

Expansion is roadmapped — see product/roadmap § Geographic scope for the phased plan. US, Singapore, and LatAm sender markets are in the pipeline, gated on rail parity at Infinia (deposit + withdraw both supported before we open the country).

Why post-KYC rather than pre-signup: we can't reliably determine the user's country before OAuth returns and KYC runs. IP geolocation is unreliable (VPNs); OAuth doesn't include country. The trade-off is that we run KYC on some users we ultimately can't serve — a real cost, but small at MVP scale.

Notes

  • Home currency is immutable at signup for MVP. If the user needs a different one later, that's an out-of-band support flow. Multi-currency users are deliberately out of scope.
  • Apple's "Hide My Email" means we may only see <random>@privaterelay.appleid.com on first sign-in and no email at all thereafter. Real email is captured during Infinia HOSTED KYC and stored on kyc_verifications.verified_identity.
  • KYC widget branding. The user sees Infinia's HOSTED widget (SumSub-powered). Whether that widget is branded as SumSub, Infinia, or white-labeled to look like GrinGo is tracked as an open question with Infinia.
  • Owner status webhook. Infinia's public OpenAPI doesn't list an owner.status_updated webhook. Until confirmed with Infinia, we detect KYC completion by polling GET /v1/accounts/owners/{uuid}/ every ~5s while the webview is open, backing off to every 60s after it closes.
  • Home-currency account provisioning happens automatically as soon as KYC status flips to COMPLETED. The account.status_updated webhook flips our provider_accounts.status from provisioning to active. The user can start depositing as soon as we display the funding instructions.
  • BRL account is not created here. It's lazily provisioned on the user's first PIX payment. See pix-payment.
  • KYC data storage. Documents and selfies never enter our systems — the HOSTED widget submits them directly to Infinia. We store a copy of the verified identity fields (name, DOB, address, doc number) on kyc_verifications.verified_identity for portability, encrypted at rest. See privacy for the data-protection posture.

Failure modes

What brokeWhere it surfaces
Apple / Google id_token verification failsPOST /auth/session → 401
Infinia rejects HOSTED owner creationPOST /kyc/start → 502 with error surfaced to mobile
User closes webview mid-KYCkyc_verifications.status stays pending; polling backs off to 60s; user can resume via same URL until expires_at
KYC declined by Infiniastatus transitions to failed; mobile shows a support-contact screen (retry flow is post-MVP)
Verified address is outside the MVP allowliststatus transitions to country_not_supported; no account provisioned; user is offered the waitlist. See Country allowlist
Account provisioning stalls in PROVISIONINGMobile shows a "still setting up your account" state; the account.status_updated webhook resolves it