TransFi ramps, virtual IBANs and fiat payouts

The five Super-Admin settings, six environment variables and two reconcilers that make up TransFi — deposits, IBANs, on-ramp, off-ramp and outbound payouts — none of which has an admin screen.

10 min readUpdated 6 August 2026transfi, payouts, onramp, offramp, iban, webhooks

TransFi is not a payment gateway with a settings page. It is five separate money paths sharing one set of credentials:

Path Direction Configured by
Hosted fiat deposit In The transfi row on the deposit gateway console
Virtual IBAN In transfiIbanEnabled
On-ramp — buy crypto In transfiOnrampEnabled + transfiOnrampCustody
Off-ramp — sell crypto Out transfiOfframpEnabled + transfiOfframpCustody
Outbound fiat payout Out A withdraw_gateway row, plus a method bound to it

There is no TransFi screen, no per-provider console, and no withdraw-gateway console — the payout side of the platform is assembled from database rows, not from an admin page. Everything on this page is platform settings, plus .env, plus scheduled jobs. If you are looking for a page to configure this on, you will not find one, and its absence is not a missing permission.

The one exception is the deposit half: TransFi appears as a card on the deposit gateway readiness console, alias transfi, described in Connecting a fiat deposit gateway.

The five settings

All five live on Admin → System → Platform Settings → Wallet → TransFi, and all five are in the Super-Admin-only protected list. A non-Super-Admin who changes any one of them gets a 403 for the whole save — none of the other changes on that tab are written either.

Key Default What switching it on does
transfiIbanEnabled "false" Allows a customer to be issued a permanent EUR bank account they can pay into at any time, instead of starting a checkout per deposit
transfiOnrampEnabled "false" Allows the on-ramp: buy crypto with local currency
transfiOnrampCustody "self" Where purchased crypto is delivered
transfiOfframpEnabled "false" Allows the off-ramp: sell crypto for local currency paid to a bank or mobile wallet
transfiOfframpCustody "self" Who sends the crypto to TransFi

Settings are stored as TEXT, so every read compares against the literal string "true". "false", "0" and an unset key are all off. Everything defaults off, so an install that has configured nothing has no exposure.

The two custody settings, and why they are a decision

self (the default) means the customer's own external wallet is the counterparty. On the on-ramp they supply an address and TransFi sends the crypto there; on the off-ramp TransFi hands back a deposit address and the customer sends from their own wallet. The platform never touches crypto: no hot wallet, no chain watcher, no custody exposure. It is a brokerage service.

platform means your own balances are the counterparty — crypto is delivered to a platform address and credited to the customer's wallet, or their wallet is debited and you send on-chain.

It needs a per-chain deposit address plus on-chain attribution (on-ramp), or a funded hot wallet plus an on-chain send path (off-ramp). None of that is wired. Rather than half-work and misplace crypto, the route returns a 501 naming what is missing and telling you to switch the mode back to self.

So selecting To a platform wallet or Debit their platform balance does not enable anything — it disables the ramp.

The six environment variables

Credentials are read from the process environment, never from the settings table. They are loaded once, at boot.

Variable Required for Notes
APP_TRANSFI_USERNAME Everything From Displai → Settings → Integration
APP_TRANSFI_PASSWORD Everything
APP_TRANSFI_MID Everything Your merchant ID
APP_TRANSFI_BASE_URL Explicit environment override. https://sandbox-api.transfi.com or https://api.transfi.com
APP_TRANSFI_SANDBOX Consulted only when the base URL is unset. false forces production
APP_TRANSFI_WEBHOOK_SECRET The two webhooks only Checked separately from the three above

The first three are checked by assertTransfiConfig, which throws a 500 naming exactly which ones are unset — "TransFi is enabled but not configured. Missing: …". The webhook secret is checked on its own, by the webhook routes only, so an install can create orders perfectly while silently rejecting every callback.

Sandbox is resolved in this order: an explicit APP_TRANSFI_BASE_URL wins and is read as sandbox unless it points at //api.transfi.com; otherwise APP_TRANSFI_SANDBOX !== "false" decides, which means the default is sandbox.

Sandbox keys against api.transfi.com return UNAUTHORIZED_CUSTOMER, and the mirror case fails the same way. This is why the base URL is an explicit override rather than being derived from NODE_ENV — a staging build pointed at sandbox has to be able to say so.

Every one of these is read at boot. Editing .env while the platform is running changes nothing until you restart. The full reference, including the optional timeout, cache, purpose-code and signature-variant variables, is in Environment variables.

The deposit surface

Everything under /api/finance/deposit/fiat/transfi:

Initiates a hosted TransFi fiat deposit and returns the checkout URL
Lists the customer's virtual IBANs. ?refresh=true re-reads status upstream
Issues a permanent virtual IBAN, or returns the one they already hold
Confirms an order upstream after the customer returns, and credits if settled
Corridor discovery with currency, or order polling with order_id
Signed provider callback. Public by design

The hosted deposit additionally requires the transfi deposit gateway row to be enabled. Without it the route answers "TransFi is not available. Ask an administrator to enable it" regardless of your settings and credentials. The row's currencies list can narrow what the MID supports, never widen it.

POST .../iban is gated on transfiIbanEnabled and on the wallet-deposit KYC feature. IBAN details are stored locally and served from there, because a customer must be able to see where to pay even when TransFi is unreachable; only status is refreshed, and only when you ask for it.

The deposit webhook URL

https://your-site.example/api/finance/deposit/fiat/transfi/webhook

Register it in Displai → Settings → Integration. The origin is built from NEXT_PUBLIC_SITE_URL, falling back to APP_PUBLIC_URL, then NEXT_PUBLIC_APP_URL, then http://localhost:3000 — if the URL looks wrong, that chain is what to fix.

The route verifies an HMAC over the raw request bytes, rejects a bad or missing signature with a 401, and then re-reads the order from TransFi before crediting anything. A verified webhook body is authenticated but not fresh — the protocol carries no timestamp and no nonce, and TransFi legitimately redelivers the same event up to nine times over roughly two hours — so the body alone is never trusted. Replays are caught on the eventId.

The ramp surface

Buys crypto with fiat
Sells crypto for fiat

Each returns a 404 naming the disabled switch when its setting is off, and a 501 when its custody mode is platform.

Selling crypto for fiat is a withdrawal in substance — money leaves the platform's orbit and lands in a bank account — so it runs the withdrawal gates, not the deposit ones: the withdraw KYC feature, then assertWithdrawTwoFactor, both before anything else happens.

That means every change you make to withdrawal 2FA policy applies here too. See Withdrawal approval and two-factor policy.

Both ramps write their record as a PAYMENT transaction anchored to the customer's fiat wallet — a bookkeeping reference only, since a self-custody ramp moves no platform balance. Consequences worth planning for:

  • Ramp rows appear under Finance → Transaction Management → Transaction Logs (/admin/finance/transaction), not in the deposit or withdrawal queues.
  • Neither reconciler picks them up. reconcileTransfiDeposits filters on type: "DEPOSIT" and reconcileTransfiPayouts on type: "WITHDRAW", so a ramp order's status is only refreshed when something polls it.

Nothing in the frontend calls /api/finance/ramp/transfi/*, and the TransFi deposit component does not use the IBAN endpoints. Switching these settings on opens the API; it does not put a Buy or Sell button in front of your customers. Treat all three as integration surfaces until a UI ships for them.

The outbound payout path

This is the only place the platform sends fiat out through a provider, and it is assembled from three pieces, none of which has a screen:

  1. A withdraw_gateway row with alias = "transfi", status true. It also carries autoDispatch, which defaults to false: automatic outbound money movement is opt-in.

  2. A withdrawal method whose gatewayAlias is "transfi". That column is not in the withdrawal method form or its API schema, so it is null on every method you create — see Withdrawal methods.

  3. Structured beneficiary fields on the withdrawal requestfirstName, lastName, country, accountType, accountValue are all mandatory. Incomplete details do not fail the withdrawal; the row is tagged dispatchBlocked and left for manual settlement.

With all three present and autoDispatch on, the dispatcher at backend/src/api/finance/withdraw/fiat/transfi/dispatch.ts runs after the debit has committed — never inside it, so a slow provider call cannot hold a wallet row lock. Its rules:

Situation What happens
Claim Compare-and-set PENDING → PROCESSING. Zero rows affected means someone else claimed it — stop. This is what stops two approvals paying the customer twice
Accepted referenceId becomes the provider order id, which begins OR-
Permanently refused Refund first, flip status second, in one transaction keyed on the transaction id
Timeout or network failure No refund. We may have been charged. The row stays PROCESSING with a null reference for the reconciler
Any failure Never fails the customer's request. The withdrawal exists, the money is debited, the outcome is recorded on the row

The platform fee is booked at settlement, never at dispatch.

The payout webhook is a separate route from the deposit one and must be registered separately:

Signed payout callback. Settles or fails a dispatched withdrawal

The two reconcilers

Both run every 5 minutes and both are listed on Admin → System → Scheduled Tasks (/admin/system/cron).

Job What it owns
reconcileTransfiDeposits Every PENDING DEPOSIT whose referenceId starts TFI- and whose metadata names TransFi. Credits, fails or expires it
reconcileTransfiPayouts Every PROCESSING WITHDRAW — settling those with an OR- reference, and resolving those orphaned between debit and dispatch

The deposit job returns immediately, doing nothing, when any of the three credentials is unset or when there is no transfi deposit gateway row at all — so a job that "ran clean" on a half-configured install has not checked anything. It also expires abandoned checkouts after 24 hours, or after the numeric value of the depositExpiration setting when one is set. Nothing else does: that setting is otherwise only honoured on the spot path, so without this job a customer who closes the TransFi window leaves a PENDING row forever.

For deposits: fund_processing is returned by GET /v3/orders but emitted by no webhook, so a webhook-only integration never observes it. Retries stop after roughly two hours. manual_review has no documented exit transition and no published SLA. Each of those is a permanently uncredited deposit without the poller.

For payouts: this is the only thing that settles or fails a dispatched payout when a webhook is lost, and the only thing that resolves a row orphaned by a crash between the debit and the dispatch. A stopped scheduler means a customer whose payout failed never gets their refund.

The payout reconciler refunds an orphan only on positive evidence that no payout exists — it lists payout orders and looks for one carrying the transaction id as its partnerId, and it will not touch an orphan younger than 30 minutes. Anything ambiguous is escalated, not guessed. The deposit reconciler shares the webhook's idempotency key, so whichever gets there first wins and the other is a no-op.

Why Approve 409s on a TransFi row

Both admin settlement paths — the withdrawal record PUT and the wallet-level approve action — refuse to complete a withdrawal a provider is already executing:

This withdrawal is being executed by TransFi and cannot be completed by hand. Its status is set by the provider webhook or the payout reconciler. Provider reference: …

The test is metadata.transfiOrderId being present, or referenceId starting with OR-. Either is proof the provider has accepted the payout.

This is not an obstruction to work around. Marking such a row COMPLETED by hand books the platform fee for a payout that may still fail, and the later failure webhook then refunds a customer whose withdrawal you already marked complete — paying them twice on the ledger.

A row that is bound to TransFi but not yet dispatched carries neither marker and is still completable, because nothing is in flight.

Bringing it up

  1. Put the three credentials in .env and leave the base URL at sandbox. Restart the backend — nothing you wrote exists to the platform until you do.

  2. Enable the transfi deposit gateway from its card on the console at /admin/finance/deposit/gateway, then open that card's own page (/admin/finance/deposit/gateway/{id}) — the currency-list editor and the Test connection button both live there, not on the console. That test is the cheapest proof the credentials work.

  3. Register the deposit webhook URL in Displai → Settings → Integration, and set APP_TRANSFI_WEBHOOK_SECRET from the secret it gives you. Restart again.

  4. Make one sandbox deposit end to end and confirm the wallet is credited. This is the only step that proves the webhook.

  5. Confirm both reconcilers are running on Scheduled tasks — check the last run time, not the absence of an error.

  6. Only then consider the ramps. Leave both custody modes at self; the alternative is refused with a 501 anyway.

  7. For payouts, create the withdraw_gateway row and bind a withdrawal method to it before you turn autoDispatch on, and register the payout webhook separately.

  8. Swap the base URL to production, re-issue the credentials and the webhook secret, and restart. Sandbox credentials will not authenticate against production.