We just completed a full security audit with Adevar Labs.
Back to Blog

Build a Crypto Off-Ramp Rail: From On-Chain Deposit to Fiat Payout

T

Thanh Vo

Author

September 10, 2026
8 min read
Build a Crypto Off-Ramp Rail: From On-Chain Deposit to Fiat Payout

A crypto off-ramp looks simple from the outside: send crypto in, receive fiat in a bank account.

On-chain, the application receives and confirms the user's deposit,
then consolidates it into treasury.

Off-chain, the payout is funded immediately from the platform's
existing fiat liquidity. Treasury rebalancing happens independently
from the individual off-ramp order.

If you want the architecture first, we covered the full pipeline here:

How Crypto Off-Ramps Work: Inside the “Cash Out to Bank” Pipeline

Here, we’ll build an inventory-based off-ramp application with the Fystack SDK: wallet creation, deposit addresses, transaction tracking, sweeping, policy checks, signing, and treasury transfers.

Off-ramp Architecture

Order model

An off-ramp order spans several transactions.

Start with an order that tracks the deposit and payout states:

TypeScript
type OffRampStatus =
  | "AWAITING_DEPOSIT"   // Waiting for deposit crypto
  | "DEPOSIT_CONFIRMED"  // Deposit confirmed on-chain
  | "PAYOUT_INITIATED"   // Fiat payout submitted to payout rail
  | "PAYOUT_PENDING"     // Fiat payout is being processed
  | "COMPLETED"          // Fiat payout completed
  | "FAILED";            // Order failed and requires recovery

type OffRampOrder = {
  id: string;
  userId: string;

  asset: string;
  network: string;

  requestedAmount: string;
  receivedAmount?: string;

  // On-chain deposit
  depositWalletId: string;
  depositTxHash?: string;

  // Fiat payout
  payoutId?: string;
  payoutCurrency: string;
  bankAccountId: string;

  status: OffRampStatus;
};

The important boundary is that the order ends with the fiat payout:

Plain Text
OffRampOrder
    │
    ├── depositWalletId
    ├── depositTxHash
    ├── payoutId
    └── status

Use a one-time deposit wallet per order

One option is to funnel every deposit into a single shared wallet:

Shared address

A shared wallet means fewer wallets to manage, but you need another way to map each deposit back to an off-ramp order. That usually means memo fields, amount matching, or other reconciliation logic, all of which add edge cases around partial payments, duplicate amounts, and concurrent deposits.

We're sidestepping that entirely. Instead, every order gets its own one-time deposit wallet:

Per deposit address

Each deposit wallet maps to exactly one off-ramp order. A deposit into Wallet A belongs to Order A, so there is no separate payment-matching step.

First install and initialize the SDK:

Bash
npm install @fystack/sdk
TypeScript
import {
  FystackSDK,
  Environment,
  WalletType,
  WalletPurpose,
  AddressType,
} from "@fystack/sdk";

const fystack = new FystackSDK({
  credentials: {
    apiKey: process.env.FYSTACK_API_KEY!,
    apiSecret: process.env.FYSTACK_API_SECRET!,
  },
  workspaceId: process.env.FYSTACK_WORKSPACE_ID!,
  environment: Environment.Production,
});

For the code example in this post, we'll use USDC on Base:

TypeScript
const ASSET = "USDC";
const NETWORK = "BASE_MAINNET";
const ADDRESS_TYPE = AddressType.Evm;

When the user creates an off-ramp:

HTTP
POST /offramps
JSON
{
  "asset": "USDC",
  "network": "BASE_MAINNET",
  "amount": "1000",
  "payoutCurrency": "USD",
  "bankAccountId": "bank_123"
}

create the internal order and its deposit wallet:

TypeScript
const orderId = crypto.randomUUID();

const wallet = await fystack.createWallet({
  name: `offramp_${orderId}`,
  walletType: WalletType.Hyper,
  walletPurpose: WalletPurpose.OneTimeUse,
  sweepTaskID: process.env.OFFRAMP_SWEEP_TASK_ID!,
});

const order = await db.offRampOrders.create({
  id: orderId,
  userId,

  asset: ASSET,
  network: NETWORK,

  requestedAmount: "1000",

  depositWalletId: wallet.wallet_id,

  payoutCurrency: "USD",
  bankAccountId: "bank_123",

  status: "AWAITING_DEPOSIT",
});

Then get the address the user should send to:

TypeScript
const deposit = await fystack.getDepositAddress(
  wallet.wallet_id,
  ADDRESS_TYPE
);

Return it:

JSON
{
  "id": "offramp_123",
  "status": "AWAITING_DEPOSIT",
  "deposit": {
    "asset": "USDC",
    "network": "BASE_MAINNET",
    "address": "0x..."
  }
}

Use the confirmed deposit amount

Creating an off-ramp order tells us how much the user intends to send. It doesn't tell us how much actually arrived on-chain.

The deposit transaction is the source of truth.

Plain Text
requestedAmount: 1000.00
receivedAmount:   999.80

Keep both values on the order. requestedAmount represents the original request; receivedAmount is populated only after the deposit has been confirmed on-chain.

The rest of the off-ramp should operate on receivedAmount. If the two values differ because of a partial deposit, fees, or another reason, handle that as a separate product rule instead of silently assuming they are equal.

Receive deposit events with webhooks

Rather than polling the deposit wallet, listen for Fystack webhook events.

When the transaction reaches the required confirmation state, Fystack sends a deposit.confirmed event to your backend. Use the wallet ID to map the event back to the off-ramp order:

TypeScript
async function handleDepositConfirmed(
  event: DepositConfirmedEvent
) {
  const order = await db.offRampOrders.findByWalletId(
    event.walletId
  );

  if (!order) return;

  // Webhooks may be delivered more than once.
  if (order.depositTxHash === event.txHash) {
    return;
  }

  await db.offRampOrders.update(order.id, {
    depositTxHash: event.txHash,
    receivedAmount: event.amount,
    status: "DEPOSIT_CONFIRMED",
  });
}

Your webhook endpoint dispatches the event to the handler:

TypeScript
app.post("/webhooks/fystack", async (req, res) => {
  const event = req.headers["x-webhook-event"];

  switch (event) {
    case "deposit.confirmed":
      await handleDepositConfirmed(req.body);
      break;
  }

  res.sendStatus(200);
});

In production, verify the webhook signature before processing the payload and keep handlers idempotent. The same event can be delivered more than once, so processing it twice should not advance or duplicate the order.

Fund the payout from fiat liquidity

Once the deposit is confirmed, the payout can proceed using the platform's available fiat liquidity.

TypeScript
interface PayoutRail {
  send(input: {
    amount: string;
    currency: string;
    bankAccountId: string;
    reference: string;
  }): Promise<{ id: string; status: string }>;

  getStatus(id: string): Promise<PayoutStatus>;
}

First determine the fiat amount:

TypeScript
const payoutAmount = await quotePayoutAmount({
  asset: order.asset,
  amount: order.receivedAmount!,
  currency: order.payoutCurrency,
});

Check that enough liquidity is available, then create the payout:

TypeScript
const available = await fiatLedger.getAvailableBalance(
  order.payoutCurrency
);

if (Number(available) < Number(payoutAmount)) {
  await alerting.notify("fiat_inventory_low", {
    orderId: order.id,
  });
  return;
}

const payout = await payoutRail.send({
  amount: payoutAmount,
  currency: order.payoutCurrency,
  bankAccountId: order.bankAccountId,
  reference: order.id,
});

await fiatLedger.debit(
  order.payoutCurrency,
  payoutAmount
);

await db.offRampOrders.update(order.id, {
  payoutId: payout.id,
  status: "PAYOUT_INITIATED",
});

The user-facing flow is now straightforward:

Plain Text
AWAITING_DEPOSIT
       ↓
DEPOSIT_CONFIRMED
       ↓
PAYOUT_INITIATED
       ↓
COMPLETED

The payout provider owns the fiat payout state from this point onward.

Consolidate deposits into treasury

Payout processing and custody consolidation are separate.

Each off-ramp order uses its own deposit wallet for simple attribution, while Fystack periodically sweeps those balances into a central MPC treasury.

Plain Text
Deposit Wallet #1 ─┐
Deposit Wallet #2 ─┼──► MPC Treasury
Deposit Wallet #3 ─┘

Create the treasury once:

TypeScript
const treasury = await fystack.createWallet({
  name: "Off-ramp Treasury",
  walletType: WalletType.MPC,
  walletPurpose: WalletPurpose.General,
});

Then configure a sweep task:

TypeScript
const sweepTask = await fystack.automation.createSweepTask({
  name: "Off-ramp deposits",
  strategy: SweepStrategy.Periodic,
  frequencyInSeconds: 60,

  destinationWalletId: process.env.TREASURY_WALLET_ID!,
  destinationType: DestinationType.InternalWallet,

  walletIds: [],
});

Attach the task when creating each deposit wallet:

TypeScript
const wallet = await fystack.createWallet({
  name: `offramp_${order.id}`,
  walletType: WalletType.Hyper,
  walletPurpose: WalletPurpose.OneTimeUse,
  sweepTaskID: process.env.OFFRAMP_SWEEP_TASK_ID!,
});

Fystack handles the consolidation transaction and signing independently of the payout lifecycle.

Rebalance liquidity

Over time, crypto accumulates in treasury while fiat liquidity is consumed by payouts.

Periodically move treasury crypto to an OTC desk, exchange, or other liquidity venue for conversion back into fiat:

Rebalance liquidity

Conversion and rebalancing can run on a schedule or when fiat liquidity falls below an operational threshold.

This keeps the three concerns separate: off-ramp orders track the user payout, Fystack manages on-chain consolidation, and treasury operations manage liquidity.

Configure the approval policy

Treasury withdrawals to exchanges, OTC desks, or other liquidity venues should be controlled by policy.

By default, every transaction has to go through an approval group before it executes. Fystack's Policy Engine lets you override that: you write rules that decide, per transaction, whether it needs approval or can go straight through.

Fystack's Policy Engine

Each rule has two parts:

  • Rule Outcome: what triggers the rule (e.g. a withdrawal being created) and what happens when it matches (e.g. bypass approval, or require it).
  • Rule Conditions: the checks that decide whether the rule applies, built from fields like withdrawal amount, whether the destination is whitelisted, or the network.

More on the policy engine: https://docs.fystack.io/product/policies

For example, this rule lets a withdrawal skip approval when all conditions match:

Plain Text
withdrawal.value_usd > 10000 && withdrawal.is_whitelisted == false && withdrawal.network_code == 'ethereum'

If nothing matches, it falls back to the default: waiting for approval.

Track payout and treasury settlement separately

The user payout and treasury movement follow different lifecycles.

For the order itself, the important states are:

Plain Text
deposit.confirmed
      ↓
payout.pending
      ↓
payout.completed

Once the deposit is confirmed and the payout is created, the payout rail owns the fiat settlement state.

Treasury operations continue separately:

Treasury operations

A sweep or rebalance event should not advance or complete the off-ramp order.

The payout handler only updates the order from payout events:

TypeScript
async function handlePayoutEvent(event: PayoutEvent) {
  const order = await db.offRampOrders.findByPayoutId(
    event.payoutId
  );

  if (!order) return;

  switch (event.status) {
    case "pending":
      await db.offRampOrders.update(order.id, {
        status: "PAYOUT_PENDING",
      });
      break;

    case "completed":
      await db.offRampOrders.update(order.id, {
        status: "COMPLETED",
      });
      break;

    case "failed":
      await db.offRampOrders.update(order.id, {
        status: "FAILED",
      });
      break;
  }
}

Only the fiat payout closes the order.

Sweeping and rebalancing are treasury concerns. They may affect available liquidity over time, but they do not represent the settlement state of a specific user payout.

Reconcile the order state

Webhooks move the order forward, but they should not be the only way to recover settlement state.

Periodically reconcile open orders against the deposit and payout systems:

Order state

For example:

TypeScript
const deposit = await fystack.getTransaction(
  order.depositTxHash!
);

const payout = await payoutRail.getStatus(
  order.payoutId!
);

Compare those results with the stored order state and update anything that was missed by a webhook.

For example, if the payout rail reports that a payout has completed but the order is still PAYOUT_PENDING, reconciliation can safely move it to COMPLETED.

Keep this process idempotent so it can run repeatedly without advancing an order twice or creating duplicate payouts.

Reconciliation also gives you a recovery path after worker crashes, delayed events, or temporary webhook failures.

Failure model

The user order and treasury operations fail independently. Handle each failure where it occurs instead of treating the entire off-ramp as one transaction.

FailureImpactRecovery
Deposit confirmed, payout not createdOrder is stuck at DEPOSIT_CONFIRMEDReconcile and retry payout creation
Payout created, webhook missedStored order state becomes staleQuery the payout rail and update the order
Payout failedUser has not received fiatMark the order FAILED and route it for retry or review
Worker crashes after an external callLocal state may not reflect the external operationPersist external IDs and use idempotency keys before retrying
Sweep delayed or failedCrypto remains in the deposit walletRetry the sweep; payout state is unchanged
Rebalance delayed or failedFiat liquidity is not replenishedRetry or route through another liquidity venue
Fiat liquidity too lowNew payouts cannot be fundedPause payout creation and alert treasury operations

Persist external IDs before advancing state, keep webhook handlers idempotent, and make every transition safe to retry.

Order reconciliation repairs user-facing state, while treasury monitoring protects liquidity for future payouts.

Putting it together

After all of that, the architecture is still small:

Fystack handles the on-chain side: wallet creation, deposit addresses, transaction detection, confirmations, KYT, sweeping, treasury custody, policy enforcement, approvals, and signing.

The payout rail handles the bank transfer, while treasury operations replenish fiat liquidity independently through an OTC desk, exchange, or other liquidity venue.

The Fystack SDK is available on npm as @fystack/sdk and is open source on GitHub: fystack/platform-sdk.

Have questions about your custody setup? Share what you are building via the form and explore how Fystack’s MPC wallets, and sweep engine fit your architecture.

Not ready yet? Join our Telegram for product updates and architecture discussions: https://t.me/+9AtC0z8sS79iZjFl

Share this post