Endpoints
Every request/response is Zod-validated. Schemas live in packages/shared/src/.
Health
GET /health
Unauthenticated. Returns { "status": "ok" }.
Auth
POST /auth/session
Exchange an Apple or Google ID token for a GrinGo session JWT. Idempotent — repeated calls upsert the users row and return a fresh session.
Request (AuthSessionRequestSchema):
{
"provider": "apple",
"idToken": "<Apple or Google id_token JWT>"
}
Provider is apple or google. The API verifies the token against the provider's JWKS (checking iss, aud, exp, signature) and upserts the users row by (auth_provider, auth_provider_user_id = sub).
Response (AuthSessionResponseSchema):
{
"sessionToken": "<HS256 JWT>",
"user": {
"id": "uuid",
"email": "jane@example.com",
"homeCurrency": "GBP",
"displayName": "Jane"
}
}
Store sessionToken in expo-secure-store; attach as Authorization: Bearer <sessionToken> on every subsequent request.
Me
GET /me
Returns the current user, KYC status, and (if provisioned) the home-currency deposit instructions.
Response (MeResponseSchema):
{
"user": {
"id": "uuid",
"email": "jane@example.com",
"homeCurrency": "GBP",
"displayName": "Jane",
"createdAt": "2026-07-15T…"
},
"kyc": {
"status": "completed",
"level": "standard",
"completedAt": "2026-07-15T…"
},
"balance": {
"currency": "GBP",
"amount": 4530
},
"deposit": {
"currency": "GBP",
"accountHolderName": "Jane Traveller",
"accountNumber": "12345678",
"sortCode": "12-34-56"
}
}
balance.amountin minor units (pence / cent).depositisnulluntil the user's Infinia account isactive. Sort code / account number shown for GBP; IBAN / BIC for EUR.
PATCH /me
Update mutable user fields. Currently only homeCurrency and displayName. homeCurrency can only be set once — subsequent attempts to change it return 409.
Request (UpdateMeRequestSchema):
{ "homeCurrency": "GBP" }
DELETE /me
Initiates account closure. Flags users.closed_at = now, purges non-essential columns immediately, and schedules retention-bound data (KYC, transactions, bank details) for hard-delete after the 6-year regulatory window. Also creates a data_subject_requests row of type erasure for audit tracking.
Response: { "status": "closed", "hardDeletionScheduledFor": "2032-07-15T…" }.
POST /me/data-request
Raises a GDPR data subject request (access, rectification, portability, restriction, objection). Erasure requests use DELETE /me.
Request (DataSubjectRequestSchema):
{
"type": "access",
"notes": "Please send my full data export"
}
Response: { "id": "uuid", "type": "access", "status": "open", "receivedAt": "…" }. Handled manually via support@gringo.pay within the 30-day SLA. See privacy.
KYC
POST /kyc/start
Creates an Infinia account owner in HOSTED mode and returns the hosted verification URL. Idempotent per user.
Request: empty body — all required data (name, contact, etc.) is collected inside Infinia's hosted widget.
Response (KycStartResponseSchema):
{
"verificationUrl": "https://verify.infiniaweb.com/…",
"expiresAt": "2026-07-15T…",
"status": "pending"
}
Mobile opens verificationUrl in expo-web-browser.
GET /kyc/status
Poll to check KYC state. Under the hood, this calls Infinia's GET /v1/accounts/owners/{uuid}/ if our cached status is still pending, and updates our row.
Response (KycStatusResponseSchema):
{
"status": "completed",
"level": "standard",
"hasActiveAccount": true
}
status:pending|completed|failed.level:basic|standard|enhanced(populated whenstatus = completed).hasActiveAccount:trueonce the user's home-currency Infinia account transitions toactivevia theaccount.status_updatedwebhook.
Bank details
The user's own bank account, used as the withdraw destination.
POST /bank-details
Discriminated on currency. Unique on (userId, currency) — repeated POSTs overwrite. Values are encrypted at rest via pgcrypto.
GBP request (CreateBankDetailsRequestSchema):
{
"currency": "GBP",
"accountHolderName": "Jane Traveller",
"accountNumber": "12345678",
"sortCode": "12-34-56"
}
EUR request:
{
"currency": "EUR",
"accountHolderName": "Jane Traveller",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX"
}
Response (BankDetailsResponseSchema) — sensitive fields masked:
{
"id": "uuid",
"currency": "GBP",
"accountHolderName": "Jane Traveller",
"accountNumber": "****5678",
"sortCode": "12-**-56",
"validatedAt": null,
"validationStatus": null,
"createdAt": "2026-07-15T…"
}
If Bank Account Validation runs (Phase 1.5), validatedAt and validationStatus reflect the last check.
GET /bank-details
Returns all stored bank details for the current user, sensitive fields masked as above.
Transactions
GET /transactions
Ledger of the user's deposits, payments, and withdrawals. Returns an array of TransactionResponse:
[
{
"id": "uuid",
"type": "deposit",
"status": "completed",
"sourceCurrency": "GBP",
"sourceAmount": 10000,
"destCurrency": null,
"destAmount": null,
"fxRate": null,
"provider": "infinia",
"failureReason": null,
"createdAt": "2026-07-15T…",
"completedAt": "2026-07-15T…"
}
]
- Amounts in minor units.
type:deposit|payment|withdraw.status:pending|settling|completed|failed(normalised — see payment-providers).- For
paymentrows, useGET /pix/payments/:idto fetch the PIX-specific detail.
PIX
POST /pix/quote
Returns the GBP/EUR → BRL rate for the review screen. Behaviour is flag-gated: while USDC_ACCOUNTS_ENABLED is off (the default, pre-rollout) this is a single direct GBP/EUR→BRL FX quote and no two-hop refund flow occurs. When the flag is on, there is no direct pair at Infinia, so the rate is the product of two internal legs (GBP/EUR→USDC and USDC→BRL) through a per-user USDC pivot, composed server-side and returned as a single rate. The USDC pivot is internal and never surfaced to the user. In the two-hop case the composed rate is not fully locked for 60s — only leg 1 (the customer's home-currency debit) is locked at its quote; leg 2 is re-quoted fresh after leg 1 settles. Leg 2 is target-anchored, so the merchant is paid exactly amountBrl or, on a definitive leg-2 failure, the payment auto-unwinds — the parked USDC is reversed back to the user's home currency and refunded (refunding → refunded). The delivered BRL never varies; what can move is the USDC the fresh leg-2 quote requires.
Request (PixQuoteRequestSchema):
{ "amountBrl": 500.00 }
Response (PixQuoteResponseSchema):
{
"quoteId": "quote_…",
"rate": 6.4838,
"sourceCurrency": "GBP",
"sourceAmount": 7710,
"gringoSpreadBps": 200,
"expiresAt": "2026-07-15T…"
}
sourceAmountin minor units, already includes GrinGo spread.502if Infinia's quote endpoint is unreachable.
POST /pix/payments
Executes a PIX payment using a quoteId obtained from POST /pix/quote.
Request (CreatePixPaymentRequestSchema):
{
"quoteId": "quote_…",
"pixKey": "12345678901",
"pixKeyType": "cpf",
"recipientName": "Merchant Ltda"
}
pixKeyvalidated perpixKeyType(isValidPixKeyin@gringo-pay/shared).recipientNameoptional; from the PIX QR if scanned.
If the quoteId has expired, POST /pix/payments returns 409 quote_expired — it does not silently re-quote. The client requests a fresh quote via POST /pix/quote and re-confirms. There are no rate-update fields on this response.
Response (ExecutePixPaymentResponseSchema):
{
"transactionId": "uuid",
"pixPaymentId": "uuid",
"status": "converting"
}
The mobile then polls GET /pix/payments/:id for terminal state. The status walks the PIX lifecycle quoted → converting → paying → completed (see GET /pix/payments/:id below).
GET /pix/payments
List of the user's PIX payments, most recent first. Array of PixPaymentResponse.
GET /pix/payments/:id
Single PIX payment. Polling this from the receipt screen is the way to observe status transitions.
Response (PixPaymentResponseSchema):
{
"id": "uuid",
"transactionId": "uuid",
"status": "completed",
"amountBrl": "500.00",
"exchangeRate": "4.901961",
"pixKey": "12345678901",
"pixKeyType": "cpf",
"recipientName": "Merchant Ltda",
"voucherId": "VCHR…",
"failureReason": null,
"expiresAt": null,
"createdAt": "2026-07-15T…",
"updatedAt": "2026-07-15T…"
}
status is one of quoted · converting (either conversion leg in flight — leg 1 GBP/EUR→USDC or the re-quoted leg 2 USDC→BRL) · paying (both legs done, BRL payout in flight) · completed, plus the leg-2-failure refund states refunding (reverse USDC→home in flight) and refunded (user returned exactly what they paid), the recoverable payout_failed (BRL parked, payout retryable via POST …/retry), the terminal non-retryable manual_review (leg 2 completed with an unexpected amount, a refund that couldn't complete, or a payout that was refunded — needs support), terminal failed, and the legacy/unused conversion_failed. The response carries the single customer-facing composed exchangeRate; there is no separate execution-rate field. The Infinia voucherId is the receipt proof once the payout settles.
Withdraws
POST /withdraws
Payout from the user's home-currency Infinia account to their own bank via FPS (GBP) or SEPA (EUR).
Request (CreateWithdrawRequestSchema):
{ "amount": 4530 }
amountin minor units (pence / cent).- Currency is inferred from the user's
homeCurrency— no cross-currency withdrawals in MVP. - Requires a
user_bank_detailsrow for the user's home currency.
Response (WithdrawResponseSchema):
{
"id": "uuid",
"type": "withdraw",
"status": "settling",
"sourceCurrency": "GBP",
"sourceAmount": 4530,
"provider": "infinia",
"expectedSettlementSeconds": 10,
"createdAt": "2026-07-15T…"
}
The mobile polls GET /transactions/:id (or reloads GET /transactions) for terminal state.
GET /withdraws
List of the user's withdrawals.