Skip to main content

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.amount in minor units (pence / cent).
  • deposit is null until the user's Infinia account is active. 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 when status = completed).
  • hasActiveAccount: true once the user's home-currency Infinia account transitions to active via the account.status_updated webhook.

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 payment rows, use GET /pix/payments/:id to 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 (refundingrefunded). 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…"
}
  • sourceAmount in minor units, already includes GrinGo spread.
  • 502 if 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"
}
  • pixKey validated per pixKeyType (isValidPixKey in @gringo-pay/shared).
  • recipientName optional; 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 }
  • amount in minor units (pence / cent).
  • Currency is inferred from the user's homeCurrency — no cross-currency withdrawals in MVP.
  • Requires a user_bank_details row 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.