Skip to content

Chapter 5: Fiat Ramps

The fiat ramp layer is what makes Capxul a complete financial operating system rather than a crypto-to-crypto payment tool. It connects the stablecoin world to the fiat world — bank accounts, mobile money, and local currency.

Flow 1: Employee Off-Ramp from Smart Account

Section titled “Flow 1: Employee Off-Ramp from Smart Account”

The core employee experience. An employee has USDC in their smart account and wants fiat in their bank account or mobile money wallet.

  • Source: Employee’s smart account (on-chain)
  • Destination: Bank account or mobile money wallet (fiat)
  • Trigger: Manual withdrawal or auto-routing rules
  • KYC: Individual KYC required (see Chapter 10)
  • Provider: Backend calls off-ramp provider API, gets provider deposit address, session key sends USDC to that address, provider converts and pays out fiat

Flow 2: Org Off-Ramp from Safe to Pay Fiat Vendor

Section titled “Flow 2: Org Off-Ramp from Safe to Pay Fiat Vendor”

An org approves an invoice from a fiat-only vendor. The Safe routes the payment through the off-ramp provider.

  • Source: Organization’s Safe (on-chain)
  • Destination: Vendor’s bank account (fiat)
  • Trigger: Invoice approval with fiat payment destination selected
  • KYB: Org must be KYB-verified
  • Provider: Same as Flow 1, but the Safe module sends USDC to the provider deposit address

An org funds their Safe treasury with fiat. HoneyCoin provides a persistent virtual bank account. Fiat deposits to this account are converted to USDC and sent to the org’s Safe on-chain.

  • Source: Fiat bank transfer to virtual account
  • Destination: Organization’s Safe (on-chain, USDC)
  • Trigger: External fiat deposit (Capxul is reactive, not initiating)
  • KYB: Required before virtual account provisioning

Flow 4: Vendor Off-Ramp from Smart Account

Section titled “Flow 4: Vendor Off-Ramp from Smart Account”

A vendor gets paid in USDC, then off-ramps from their own smart account. Identical to Flow 1 — the facade does not distinguish between employee and vendor smart accounts.

A Note on Terminology: Provider Deposit Address

Section titled “A Note on Terminology: Provider Deposit Address”

Off-ramp providers generate a unique address per transaction that they control. You send USDC to it. The provider now has your USDC unconditionally. They then pay out fiat to the specified bank account because that is what their API contract says they will do. There is no conditional release, no neutral party, no smart contract holding funds in between.

The unique address exists for accounting correlation — matching an API request to a specific deposit. This is a standard pattern across all crypto off-ramp providers.

Throughout this document, this is called a provider deposit address. If you encounter “escrow” in provider documentation, it refers to the same thing, but the term is misleading — no escrow mechanism exists.

HoneyCoin is the only provider at launch. But off-ramp and on-ramp providers vary by jurisdiction, fee structure, supported currencies, payout methods, settlement speed, and reliability. No single provider covers every market. As Capxul expands, additional providers will be needed.

The facade ensures that adding a provider is a backend configuration change, not a platform rewrite. The rest of the system (Convex records, UI, session key logic, indexer) never knows which provider fulfilled a transaction.

Every off-ramp provider must expose:

  • getSupportedCurrencies(country) — available fiat currencies and payout methods
  • getSupportedBanks(country, currency) — banks and mobile money operators
  • getQuote(amount, token, chain, currency, country, payoutMethod) — expected fiat amount, exchange rate, fees
  • createTransaction(amount, token, chain, currency, country, payoutDetails, webhookUrl, externalReference) — initiates the off-ramp, returns a provider deposit address with expiry
  • getTransactionStatus(providerTransactionId) — polls status (fallback if webhooks are delayed)
  • parseWebhook(payload, signature) — validates and parses incoming webhooks
  • provisionVirtualAccount(orgId, orgName, country, currency) — creates a persistent virtual bank account
  • getVirtualAccountStatus(accountId) — current state of the virtual account
  • parseDepositWebhook(payload, signature) — validates incoming deposit notifications

A Convex table rampProviders stores provider configuration: ID, type (off_ramp, on_ramp, both), API base URL, supported countries, currencies, payout methods, active flag, and priority. At launch: one row (HoneyCoin, type both, countries [NG, GH, KE, UG]).

When a fiat transaction is initiated:

  1. Look up destination country and currency
  2. Find active providers supporting that country, currency, and payout method
  3. One provider: use it. Multiple: select by priority (future: lowest fee or best quote)
  4. No provider: reject with a clear error

This runs in the Convex backend. The frontend never selects a provider.

Off-ramp and on-ramp transactions do not belong in financialDocuments. Financial documents are accounting artifacts. Fiat transactions are operational processes with different lifecycles, fields, and failure modes. The two tables reference each other — a completed off-ramp generates a receipt in financialDocuments.

Shared fields: transaction ID, direction (off_ramp/on_ramp), status, org ID, actor ID/type, source type (smart_account/safe), source address, provider ID, provider transaction ID, external reference, timestamps.

Off-ramp fields: crypto amount, token, chain, fiat amount, currency, exchange rate, provider fee, net fiat amount, payout method (bank_transfer/mobile_money), payout details, provider deposit address, deposit address expiry, on-chain tx hash, fiat payout reference.

On-ramp fields: fiat amount deposited, currency, exchange rate, provider fee, crypto amount minted, destination chain/address, mint tx hash, virtual account ID.

requested -> quoted -> deposit_address_generated -> crypto_sent
-> crypto_confirmed -> fiat_processing -> fiat_complete

Failure states: failed (before crypto sent), expired (quote or address expired), tx_failed (on-chain revert), refunding -> refunded (provider refunding), fiat_failed (bank rejection).

StatusWhat Happened
requestedUser initiated. Convex record created.
quotedProvider returned exchange rate and fees.
deposit_address_generatedUser confirmed. Provider returned deposit address (1-hour expiry for HoneyCoin).
crypto_sentOn-chain tx submitted via session key or module. Tx hash recorded.
crypto_confirmedOn-chain tx confirmed (indexer or receipt polling). Provider has the funds.
fiat_processingProvider webhook: fiat payout initiated.
fiat_completeProvider webhook: fiat landed. Terminal success. Receipt generated.
deposit_received -> converting -> minting -> mint_confirmed -> complete

On-ramp is simpler because Capxul is reactive. The org (or someone paying the org) makes a bank transfer. Capxul receives a webhook, tracks the conversion, and confirms the USDC lands in the Safe (detected by the indexer).

  1. User initiates. Selects amount, fiat currency, payout destination. Frontend calls Convex mutation.
  2. Convex creates record. Status: requested. Validates: KYC verified? Sufficient balance? Valid destination?
  3. Backend gets quote. Calls provider via facade. Record advances to quoted. Quote returned to user.
  4. User confirms. Backend calls createTransaction. Provider returns deposit address and expiry. Record: deposit_address_generated.
  5. Backend submits on-chain tx. Session key constructs UserOp sending exact crypto amount to provider deposit address. Tx hash recorded. Status: crypto_sent.
  6. Indexer confirms. Transfer event detected. Status: crypto_confirmed.
  7. Provider webhook: processing. Status: fiat_processing.
  8. Provider webhook: complete. Status: fiat_complete. Receipt generated in financialDocuments.

Same flow but triggered by invoice approval with fiat destination. Source is the Safe (not smart account), executed via Safe module (not session key). The invoice transitions to paid when fiat_complete is reached.

The provider deposit address expires after 1 hour (HoneyCoin). The backend submits the on-chain transaction immediately after receiving the address. If submission fails, the address expires unused — no funds at risk. If the transaction takes unusually long to confirm, the backend monitors and resubmits with higher gas before the window closes.

The fiat transaction’s Convex ID is the externalReference passed to the provider. Duplicate webhooks for an already-completed transaction are no-ops. Status transitions are validated (cannot jump from requested to fiat_complete).

When an org completes KYB and requests on-ramp:

  1. Backend calls provisionVirtualAccount
  2. Provider returns persistent virtual account details (account number, bank name)
  3. Stored in Convex on the org record
  4. Org admin sees details in their dashboard
  1. External party sends fiat to the virtual account
  2. Provider webhook: deposit_received. Fiat transaction record created.
  3. Provider converts fiat to USDC and sends to Safe address. Status advances through converting -> minting.
  4. Indexer detects USDC Transfer into the Safe. Treasury balance updates. Status: mint_confirmed.
  5. Status: complete. Deposit appears in treasury activity feed.

Auto-routing lets employees configure rules like “send 60% to GTBank and keep 40% in crypto” and have this execute automatically on each claim.

Stored in Convex per employee:

  • Enabled flag
  • Trigger type: on_claim (at launch). scheduled and threshold designed in schema but deferred.
  • Rules: ordered list of { destinationType, destinationDetails, percentage }. Must sum to 100%.
  1. Indexer detects a claim event
  2. processClaimEvent mutation creates the claim receipt and checks: auto-routing enabled?
  3. If yes, schedules an auto-routing action
  4. The action reads routing rules and claimed amount
  5. For each fiat destination: initiates off-ramp (without user confirmation — pre-approved via routing rules and session key grant)
  6. For each crypto wallet destination: direct transfer via session key
  7. “Retain” percentage stays in the smart account

Each routing destination is an independent transaction. If one destination fails (provider down, tx reverts), funds for that destination remain in the smart account. The employee is notified. Other destinations proceed independently. Auto-routing never fails atomically.

Scheduled triggers require periodic balance checking across all employees. Threshold triggers require polling or hooking every Transfer event. On-claim is a natural hook — the indexer already detects claims. Adding “and then route” is minimal. It covers the most common case: employee claims pay and wants it split immediately.

The session key is what allows the backend to move funds from a smart account without requiring the employee to sign every transaction. See Chapter 3: Session Key Model for the authoritative technical reference on V1 session key capabilities.

The session key is granted during initial routing setup. The on-chain whitelist includes the USDC contract (Path A) or the CapxulRouter contract (Path B). Destination enforcement happens in Convex (Path A) or on-chain via the CapxulRouter allowlist (Path B).

Changing routing percentages, adding/removing bank accounts (fiat details are in Convex), changing trigger type, pausing auto-routing, swapping the off-ramp provider.

Adding a new token, changing per-transaction limits, session key expiry renewal (every 6 months), migrating from Path A to Path B.

If the backend is compromised: an attacker can trigger off-ramp transactions to bank accounts configured in the user’s profile. Every fraudulent transaction leaves an audit trail in both Convex and the off-ramp provider. The fiat destination is a real, KYC-verified bank account. Transaction amounts are capped by the user’s configured limits.

A single Convex HTTP action receives webhooks from all providers. It validates the signature, parses with the provider-specific parser, looks up the fiat transaction by provider transaction ID, and calls the status transition mutation.

Delayed: Not a problem. The record sits in its current status.

Duplicated: Not a problem. Status transitions are idempotent.

Lost: The real concern. A Convex scheduled function runs every 5 minutes scanning for transactions stuck in intermediate states. For each, it calls getTransactionStatus on the provider. This is polling-as-fallback.

The endpoint is public. It validates webhook signatures, verifies the referenced transaction exists, and checks that the status transition is valid. Invalid webhooks are logged and dropped.

Verification checks run before any provider interaction. See Chapter 10 for the full verification architecture.

  • Employee off-ramp: getActorVerificationLevel checks individual KYC tier against jurisdictional thresholds
  • Org off-ramp/on-ramp: getActorVerificationLevel checks org KYB status
  • Auto-routing: Verification checked on every execution, not just at session key grant time (verifications can expire)
  • HoneyCoin fee: 0.3-1% of transaction value
  • FX spread: 0.2-0.8%
  • Gas: negligible on Base
  • Bundler cost: typically < $0.01 on Base

For a 500 USDC off-ramp to NGN, the employee receives approximately 485-495 USDC equivalent in naira after fees and spread.

Minimal: webhook endpoint (Convex HTTP action), status polling (Convex scheduled function), session key transactions (Base gas). The dominant cost is the provider’s per-transaction fee, not Capxul’s infrastructure.