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.
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:
?refresh=true re-reads status upstreamcurrency, or order polling with order_idThe 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/webhookRegister 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
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.
reconcileTransfiDepositsfilters ontype: "DEPOSIT"andreconcileTransfiPayoutsontype: "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:
-
A
withdraw_gatewayrow withalias = "transfi",statustrue. It also carriesautoDispatch, which defaults to false: automatic outbound money movement is opt-in. -
A withdrawal method whose
gatewayAliasis"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. -
Structured
beneficiaryfields on the withdrawal request —firstName,lastName,country,accountType,accountValueare all mandatory. Incomplete details do not fail the withdrawal; the row is taggeddispatchBlockedand 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:
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
-
Put the three credentials in
.envand leave the base URL at sandbox. Restart the backend — nothing you wrote exists to the platform until you do. -
Enable the
transfideposit 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. -
Register the deposit webhook URL in Displai → Settings → Integration, and set
APP_TRANSFI_WEBHOOK_SECRETfrom the secret it gives you. Restart again. -
Make one sandbox deposit end to end and confirm the wallet is credited. This is the only step that proves the webhook.
-
Confirm both reconcilers are running on Scheduled tasks — check the last run time, not the absence of an error.
-
Only then consider the ramps. Leave both custody modes at
self; the alternative is refused with a 501 anyway. -
For payouts, create the
withdraw_gatewayrow and bind a withdrawal method to it before you turnautoDispatchon, and register the payout webhook separately. -
Swap the base URL to production, re-issue the credentials and the webhook secret, and restart. Sandbox credentials will not authenticate against production.
Related
- Connecting a fiat deposit gateway — the readiness card, the credential test, and how test mode is inferred.
- Environment variables — every
APP_TRANSFI_*variable, including the purpose code and the signature-variant pin. - Withdrawal approval and two-factor policy — the four doors, one of which is the off-ramp.
- Withdrawal methods — where a method's provider binding would live.
- Scheduled jobs — both reconcilers, and how to prove the scheduler is running.
- Working the withdrawal queue — what Approve does, and the 409 in context.