Skip to main content

Payment providers

Everything that moves money runs through Infinia. This doc covers how Infinia is wired behind an abstraction so future vendor changes don't require route-handler rewrites, and what parts of Infinia's API we depend on.

The PaymentProvider interface

Route handlers never import the Infinia client directly. They talk to a thin PaymentProvider interface defined in apps/api/src/providers/types.ts:

interface PaymentProvider {
readonly name: "infinia";

// Onboarding
createOwner(input: OwnerInput): Promise<OwnerHandle>;
getOwnerStatus(providerOwnerId: string): Promise<OwnerStatus>;
createAccount(input: AccountInput): Promise<AccountHandle>;

// Balances & movements
getMovements(providerAccountId: string, since?: Date): Promise<Movement[]>;

// FX
createQuote(input: QuoteInput): Promise<Quote>;

// Money movement
createInternalTransfer(input: InternalTransferInput): Promise<InternalTransferHandle>;
createPayout(input: PayoutInput): Promise<PayoutHandle>;
getPayoutStatus(providerPayoutId: string): Promise<PayoutStatus>;

// Webhooks
parseWebhook(req: { headers: Headers; body: string }): Promise<ProviderEvent | null>;

// Ops
healthCheck(): Promise<{ healthy: boolean; latencyMs: number }>;
}

Every method returns a normalised shape — Infinia-specific field names (quote_id, payout_id, movement_id, origin_id, etc.) are translated at the interface boundary. Consumers of the interface see providerQuoteId, providerPayoutId, etc.

Why this matters: the migration path back to Model B or a future swap to a different payments vendor requires only a new PaymentProvider implementation — no changes to routes, ledger writes, or webhook handlers.

Infinia surface we depend on

Infinia's API is much broader than what we use. The subset we touch:

EndpointPurposeGrinGo use
POST /v1/accounts/owners/Create an account owner (KYC subject)One per user, at signup
GET /v1/accounts/owners/{uuid}/Poll owner statusDetect PENDING → COMPLETED during KYC
POST /v1/accounts/Create a virtual accountOne per user per currency (GBP/EUR at signup; BRL lazily on first PIX)
GET /v1/accounts/{id}/Reconcile balanceNightly reconciliation cron
GET /v1/accounts/{id}/movements/List credits/debitsBackfill / reconciliation
POST /v1/accounts/internal-transfer/quote/Lock an FX rateEvery PIX (60s lock)
POST /v1/accounts/internal-transfer/Execute an FX-priced transferPIX — two legs, GBP/EUR → USDC then USDC → BRL
POST /v2/payoutsPush funds to an external destinationPIX → merchant; FPS/SEPA → user's own bank
GET /v2/payouts/{payout_id}/Poll payout statusManual retry / reconciliation

We do not use Infinia's Identity Validation endpoints (AR/BR document validation only — wrong audience for UK/EU tourists), Payins (we don't accept inbound payments, only push), or Claims.

KYC via Infinia HOSTED mode

Infinia offers three ways to create an account owner:

  • HOSTED — Infinia serves a hosted verification widget (SumSub-powered under the hood). The user completes KYC end-to-end inside that widget.
  • EXTERNAL — we bring our own KYC provider and hand Infinia a share token. Only SumSub is supported today (Persona is on Infinia's roadmap).
  • SELF_DECLARED — we collect identity data and documents ourselves and upload them via Infinia's pre-signed URLs. Requires Infinia's compliance team to approve our KYC process before production use.

We use HOSTED for MVP. One vendor bill, zero KYC infrastructure to build, and Infinia's widget handles the friction of collecting tax_id / tax_id_country / documents — data we'd otherwise have to design a form flow for.

HOSTED also gives us a meaningful GDPR posture win: documents and selfies never enter our systems, so we avoid becoming a controller of Article 9 special-category (biometric) data. We only store the result of verification. See privacy.

Infinia call detail:

  • POST /v1/accounts/owners{ type: "INDIVIDUAL", kyc_mode: "HOSTED" }
  • Poll → GET /v1/accounts/owners/{uuid}/
  • POST /v1/accounts{ owner_id, country: "GB", currency: "GBP", products: ["PAYINS", "PAYOUTS", "INTERNAL_TRANSFER"] }

Open question with Infinia: whether there's an owner.status_updated webhook we're missing. Until confirmed, we poll GET /v1/accounts/owners/{uuid}/ every ~5s while the KYC webview is open, backing off to every 60s after.

Verified identity storage. When KYC completes, we cache Infinia's returned individual payload on kyc_verifications.verified_identity_encrypted (encrypted at rest). This preserves the migration door — we can replay identity to a new vendor without re-onboarding users. Trade-off is real GDPR exposure; see privacy for the alternative (store only queryable fields, fetch the rest from Infinia on demand).

Future migration: when Persona lands in Infinia's EXTERNAL mode, we can swap HOSTED → EXTERNAL if brand control during KYC becomes worth the added integration cost. The PaymentProvider interface change is small.

Country allowlist (MVP)

Independent of KYC pass/fail, MVP restricts users to UK + EU-27 senders. Enforcement is a post-KYC country check against verified_identity.individual.address.country; addresses outside the allowlist transition the KYC row to country_not_supported and no provider_accounts row is provisioned. Full detail in flows/onboarding § country allowlist; expansion phasing in product/roadmap § Geographic scope.

Why the allowlist lives here in architecture and not just in policy: it's the boundary that keeps our MVP contract with Infinia honest. Infinia supports many countries; we pick the subset where they have full deposit + withdraw coverage. Adding a country to the allowlist requires (a) confirming Infinia has both rails and (b) confirming our custodial / regulatory posture for that market.

PIX payment mechanics

Infinia doesn't accept cross-currency source → destination on POST /v2/payouts — the source account currency must match the destination currency. FX is done via the dedicated internal-transfer mechanism.

Infinia also offers no direct GBP/EUR→BRL pair. The conversion therefore routes through a per-user USDC pivot account in two internal-transfer legs — GBP/EUR → USDC, then USDC → BRL. The USDC account is an internal settlement pivot only: funded by leg 1 and drained by leg 2, it nets to ~zero, is excluded from GET /virtual-accounts and /me, and is never surfaced to the user. (Its regulatory treatment — e-money vs crypto-asset while transiently held — is an open question under review; nothing here asserts a settled position.)

That gives us a five-step PIX flow: display quote, internal-transfer leg 1 (→ USDC), a fresh leg-2 quote after leg 1 settles, internal-transfer leg 2 (→ BRL), payout. It is being rolled out behind the USDC_ACCOUNTS_ENABLED flag (default off).

Locked-rate UX

The FX quote endpoint returns a quoteId with a lock window (5s–1800s configurable). We use 60s locks — long enough to cover realistic user think-time between reviewing and tapping, short enough not to widen the spread meaningfully. Wise and similar apps use the same pattern.

The quote call is not "extra" — we need it to display the review-screen price anyway, and reusing its quoteId to execute leg 1 is free. Leg 2 is the exception: because it can't run until leg 1 settles (possibly via an async webhook, well past the lock), it is re-quoted fresh against the merchant BRL just before it executes — so only the leg-1 debit is truly locked.

If a quote expires between review and tap, the API silently re-quotes and returns the new price to the mobile app; if the number changed by more than 0.1%, the app shows a "rate updated" confirmation prompt. Otherwise it just proceeds.

Idempotency

We derive Infinia idempotency handles deterministically (each key is ≤36 chars, Infinia's limit):

  • POST /internal-transfer (leg 1) — idempotency_key = "xfer" + paymentId with dashes stripped (derived from the payment id; leg 1 always runs at its locked quote).
  • POST /internal-transfer (leg 2) — idempotency_key = "x2" + paymentId with dashes stripped. Leg 2 runs once per payment (a definitive leg-2 failure unwinds to a refund — there is no leg-2 re-quote), so the key is derived from the payment UUID, not the provider quote id: InfiniaFxQuote.id isn't UUID-shaped, and sanitizing/truncating it wasn't collision-proof. A re-drive of the ambiguous leg-2 transfer reuses the same key and dedupes.
  • POST /internal-transfer (refund unwind) — idempotency_key = "rfnd" + paymentId with dashes stripped (same payment-UUID derivation; the reverse leg also runs once per payment).
  • POST /v2/payoutsoriginId = "payout-" + paymentId.

A retried POST /pix/payments returns Infinia's original resources for an in-flight leg instead of creating duplicates.

Failure recovery: parked-balance and refund problems

The two-hop route can leave money mid-flow at two points. They are handled differently, because a point-of-sale flow can't wait on a user retry for the conversion leg.

Leg-2 definitively failed → auto-unwind refund (refundingrefunded). Leg 1 completed, so the payment is holding USDC in the pivot, but the USDC → BRL leg definitively failed (transfer FAILED/rejected, or leg 1 under-delivered so a fresh leg-2 quote can't be satisfied — no funds moved on leg 2). Rather than park the USDC and wait for a user to retry, the payment auto-unwinds: it reverses the parked USDC back to the user's home currency and refunds them. refunding is the PENDING state (the reverse USDC→home leg is in flight); refunded is TERMINAL — the user was returned EXACTLY what they paid (the reverse FX is target-anchored on home_debited, the exact home amount leg 1 debited, so it never over-refunds; any surplus USDC stays parked) and the withdrawal is reversed. There is no user-facing retry for a conversion failure. The unwind fires ONLY on a definitive leg-2 failure — an ambiguous leg 2 stays converting and is re-driven by recovery first, so the USDC is never unwound while leg 2 might still land (no double-spend). If the parked USDC can't buy back the full amount paid (adverse move, and there's no treasury/house account to top up the difference yet), or the reverse leg itself fails / delivers the wrong amount, the row goes to manual_review (terminal, non-retryable — a human resolves it) with the home amount still owed recorded in refund_absorbed.

Payout failed → BRL parked (payout_failed). Both conversion legs completed but the PIX payout failed (invalid PIX key on the receiver's side, timeout, network blip — common). This case is user-retryable: POST /pix/payments/:id/retry re-issues the payout with the same originId (no second FX). The BRL sits in the user's own BRL account meanwhile.

POST /pix/payments/:id/retry therefore handles ONLY payout_failed; conversion failures are resolved by the automatic unwind, not a retry. The refund makes GrinGo eat the round-trip FX cost on that payment, factored into unit economics as an expected-loss line item. The remaining open decision is the shortfall case — whether to fund a treasury/house account to top up an adverse-move refund rather than route it to manual_review (see the two-hop change proposal).

Webhooks

Infinia posts webhooks to a per-resource callback_url we register when creating the resource (or a per-account webhook_url for movements). All webhooks land at POST /webhooks/infinia/:secret:

  • The :secret path segment is matched against INFINIA_WEBHOOK_SECRET in constant time (first-line defence).
  • Verification is behind a swappable strategy in InfiniaProvider.parseWebhook — if Infinia publishes an HMAC scheme later, we swap in header verification without touching the handler.
  • Each event is parsed into a normalised ProviderEvent and dispatched to a shared handleProviderEvent(db, ev, provider) function.
type ProviderEvent =
| { kind: "off-ramp.completed"; providerRef: string; fiatAmount: string; fiatCurrency: string; endToEndId?: string }
| { kind: "off-ramp.failed"; providerRef: string; reason: string }
| { kind: "internal-transfer.completed"; providerRef: string; sourceAmount: number; destinationAmount: number; rate: number }
| { kind: "internal-transfer.failed"; providerRef: string; reason: string }
| { kind: "deposit.received"; providerAccountId: string; amount: number; currency: string; sourceReference?: string }
| { kind: "kyc.updated"; providerOwnerId: string; status: "completed" | "failed" };

Idempotency at the webhook layer: webhook_events unique on (provider, external_event_id). Infinia doesn't emit a globally-unique event id, so we synthesise one from sha256(kind + ":" + providerRef + ":" + updatedAt).

Config

Bindings on apps/api/wrangler.toml:

BindingTypePurpose
INFINIA_API_USERNAMESecretHTTP Basic username
INFINIA_API_PASSWORDSecretHTTP Basic password
INFINIA_WEBHOOK_SECRETSecretPath secret for /webhooks/infinia/:secret
INFINIA_COMPANY_IDVarParent company id, sent as x-company-id header
INFINIA_BASE_URLVarhttps://app2test.infiniaweb.com/infinia_api in sandbox; production URL when Infinia issues it

Open questions we're tracking with Infinia

  1. Owner status webhook — is there one we're missing, or is polling required?
  2. Webhook signature scheme — the OpenAPI describes delivery but not signing. Interim: secret-in-path.
  3. Production base URL — sandbox is app2test.infiniaweb.com; awaiting production hostname.
  4. KYC level reached by HOSTED — do we get BASIC / STANDARD / ENHANCED by default, and which do we need for our transaction ceiling?
  5. HOSTED widget branding — SumSub-branded, Infinia-branded, or white-label option?
  6. Fee schedule — GBP/EUR receive, GBP↔BRL FX spread, PIX payout. Feeds unit economics.
  7. UK/EU custodial licensing coverage — Model A hinges on this. See custody-model.
  8. US payouts roadmap and ETA — needed to unblock Phase 2 US expansion. Payouts endpoint currently lists AR, BO, BR, CL, CO, EU (which covers UK), GLOBAL crypto, MX, PY, PE — no US variant.