Skip to main content

Off-Ramp (Sell) Flow

This guide walks through the complete off-ramp integration — allowing your customers to sell crypto and receive fiat. The sell flow uses a two-phase process: Phase 1 generates a crypto deposit address, and Phase 2 submits beneficiary details for the fiat payout.

Flow Overview

Two swimlanes above two phase bands. On your server, step 1 POSTs to /ramp-intents with type sell to get an intentId; the customer is redirected to your frontend, where step 2 GETs /off-ramp/quotes for pricing. Phase one, deposit: step 3 POSTs /off-ramp/sell/checkout to get a deposit address, step 4 the customer sends crypto to it, step 5 waits for deposit confirmation. Phase two, payout: step 6 GETs the payout preview for the required beneficiary fields, step 7 renders the beneficiary form for the customer to fill in, step 8 POSTs /off-ramp/sell/payout to send the fiat, and step 9 the intent completes. The required fields differ by corridor, which is why step 6 exists.
Selling crypto: deposit first, then the fiat payout

Step 1: Create a Sell Intent (Server-Side)

Create a ramp intent with type: "sell" from your backend.

// Server-side — requires API key
const createSellIntent = async (customerEmail, walletAddress) => {
const response = await fetch('https://api.zenpayz.com/payment/api/v1/ramp-intents', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.ZENPAYS_API_KEY}`,
'x-merchant-id': process.env.ZENPAYS_MERCHANT_ID,
'x-request-id': `req_${Date.now()}`,
},
body: JSON.stringify({
type: 'sell',
wallets: [{ network: 'tron', address: walletAddress }],
fiatCurrency: 'USD',
cryptoCurrency: 'usdt_tron',
customerEmail,
successUrl: 'https://yourapp.com/sell/success',
cancelUrl: 'https://yourapp.com/sell/cancel',
}),
});

const { data } = await response.json();
return data; // { intentId, redirectUrl, expiresAt }
};

Step 2: Get Sell Quotes

Fetch quotes to show the customer how much fiat they'll receive for their crypto.

const getSellQuotes = async (cryptoAmount) => {
const params = new URLSearchParams({
source: 'usdt_tron',
destination: 'USD',
amount: String(cryptoAmount),
});

const response = await fetch(
`https://api.zenpayz.com/payment/api/v1/off-ramp/quotes?${params}`
);
const { data } = await response.json();

data.quotes.forEach((quote) => {
console.log(`Provider: ${quote.onramp}`);
console.log(` You send: ${quote.inputAmount} USDT`);
console.log(` You receive: ${quote.outputAmount} USD`);
console.log(` Rate: ${quote.rate}, Fee: ${quote.totalFee}`);
});

return data.quotes;
};

const quotes = await getSellQuotes(20);
const selectedQuote = quotes[0]; // Or let user choose

Step 3: Phase 1 — Create Sell Deposit

Generate a deposit address for the customer to send crypto to.

const createDeposit = async (intentId, quote) => {
const response = await fetch(
'https://api.zenpayz.com/payment/api/v1/off-ramp/sell/checkout',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
intentId,
cryptoCurrency: 'USDT',
chain: 'tron',
fiatCurrency: 'USD',
cryptoAmount: quote.inputAmount,
fiatAmount: quote.outputAmount,
rate: quote.rate,
totalFee: quote.totalFee,
}),
}
);

const { data } = await response.json();

// Display to customer
console.log(`Send ${data.expectedCryptoAmount} ${data.currency} to:`);
console.log(`Address: ${data.depositAddress}`);
console.log(`Network: ${data.chain}`);
if (data.memo) console.log(`Memo: ${data.memo}`);

// Save for Phase 2
return {
cryptoDepositId: data.cryptoDepositId,
depositAddress: data.depositAddress,
expectedAmount: data.expectedCryptoAmount,
};
};
Display to Customer

Show the deposit address with a QR code and a countdown timer based on expiresAt. Remind the customer to send the exact expectedCryptoAmount on the correct chain.

Step 4: Wait for Deposit Confirmation

After the customer sends crypto, the system automatically detects and confirms the deposit. You can poll the intent status or listen for WebSocket events.

const waitForDeposit = async (intentId) => {
console.log('Waiting for crypto deposit...');

while (true) {
const response = await fetch(
`https://api.zenpayz.com/payment/api/v1/ramp-intents/${intentId}`
);
const { data } = await response.json();
const phase = data.intent.metadata?.phase;

if (phase === 'awaiting_payout' || phase === 'completed') {
console.log('Deposit confirmed!');
return data.intent;
}

if (data.intent.status === 'expired' || data.intent.status === 'cancelled') {
throw new Error(`Intent ${data.intent.status}`);
}

// Poll every 10 seconds
await new Promise((resolve) => setTimeout(resolve, 10000));
}
};

Step 5: Phase 2a — Get Payout Preview

Once the deposit is confirmed, fetch the required beneficiary fields for the fiat payout.

const getPayoutPreview = async (intentId, fiatCurrency, country = 'US') => {
const params = new URLSearchParams({ intentId, fiatCurrency, country });

const response = await fetch(
`https://api.zenpayz.com/payment/api/v1/off-ramp/sell/payout-preview?${params}`
);
const { data } = await response.json();

console.log(`Provider: ${data.tspDisplayName}`);
console.log(`Payout: ${data.fiatAmount} ${data.fiatCurrency}`);
console.log(`Fees: ${data.fees.total}`);
console.log(`Required fields: ${data.requiredFields.length}`);

return data;
};

const preview = await getPayoutPreview(intentId, 'USD', 'US');

// Dynamically render form from requiredFields
preview.requiredFields.forEach((field) => {
// Create form inputs based on field.type, field.label, field.required, etc.
console.log(` ${field.required ? '*' : ' '} ${field.label} (${field.fieldName})`);
});

Step 6: Phase 2b — Submit Payout

Collect the beneficiary details from the customer and submit the payout.

const submitPayout = async (intentId, cryptoDepositId, beneficiaryDetails) => {
const response = await fetch(
'https://api.zenpayz.com/payment/api/v1/off-ramp/sell/payout',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
intentId,
cryptoDepositId,
fiatCurrency: 'USD',
country: 'US',
beneficiaryDetails,
}),
}
);

const { data } = await response.json();

console.log(`Payout ID: ${data.payoutId}`);
console.log(`Status: ${data.status}`);
console.log(`Amount: ${data.amount} ${data.currency}`);

return data;
};

// Example: customer filled in the form
const payout = await submitPayout(intentId, deposit.cryptoDepositId, {
receiverFirstName: 'John',
receiverLastName: 'Doe',
receiverCountry: 'US',
receiverAccountNumber: '123456789',
receiverBankName: 'Bank of America',
receiverBankCode: 'BOFAUS3N',
receiverAddressLine1: '123 Main St',
receiverCity: 'New York',
receiverState: 'NY',
receiverPinCode: '10001',
remittancePurpose: 'PAYP001 - Family Support',
sourceOfFund: 'PAYF001 - Salary',
relationship: 'PAYR001 - Self',
});

// Intent is now completed — redirect customer
window.location.href = '/sell/success';

Step 7: Receive Webhook Notification

After the payout is submitted (or if it fails), ZenPay delivers a webhook to your configured endpoint.

ramp.completed — fiat payout has been submitted successfully:

{
"event_type": "ramp.completed",
"payment_data": {
"merchant_id": "m_abc123",
"intentId": "ri_1710345678000_a1b2c3d4e5f6g7h8",
"type": "sell",
"status": "completed",
"fiatCurrency": "USD",
"cryptoCurrency": "usdt_tron",
"amount": 20,
"payoutId": "po_xyz789",
"payoutAmount": 19.50,
"payoutCurrency": "USD",
"completedAt": "2026-03-16T14:30:00.000Z"
}
}

ramp.failed — payout submission failed:

{
"event_type": "ramp.failed",
"payment_data": {
"merchant_id": "m_abc123",
"intentId": "ri_1710345678000_a1b2c3d4e5f6g7h8",
"type": "sell",
"status": "failed",
"fiatCurrency": "USD",
"cryptoCurrency": "usdt_tron",
"amount": 20,
"reason": "Payout submission failed",
"failedAt": "2026-03-16T14:30:00.000Z"
}
}
Webhook vs Polling

We recommend using webhooks as the primary notification mechanism. Webhooks are delivered as soon as the ramp completes or fails — no delay. See the Webhooks guide for setup, signature verification, and retry behavior.

Complete Example

// Full off-ramp flow
const runSellFlow = async () => {
// Step 1: Create intent (server-side)
const intent = await createSellIntent('customer@example.com', 'TLfMk...');
console.log(`Intent: ${intent.intentId}`);

// Step 2: Get quotes
const quotes = await getSellQuotes(20);
const selectedQuote = quotes[0];

// Step 3: Phase 1 — create deposit
const deposit = await createDeposit(intent.intentId, selectedQuote);
console.log(`Send USDT to: ${deposit.depositAddress}`);

// Step 4: Wait for deposit confirmation
await waitForDeposit(intent.intentId);

// Step 5: Phase 2a — get payout preview
const preview = await getPayoutPreview(intent.intentId, 'USD', 'US');

// Step 6: Phase 2b — submit payout (with customer's details)
const payout = await submitPayout(intent.intentId, deposit.cryptoDepositId, {
receiverFirstName: 'John',
receiverLastName: 'Doe',
receiverCountry: 'US',
receiverAccountNumber: '123456789',
receiverBankName: 'Bank of America',
// ... other fields from preview.requiredFields
});

console.log(`Done! Payout ${payout.payoutId} is ${payout.status}`);
};

Error Handling

PhaseErrorRecovery
Intent creationValidation errorCheck required fields and retry
Deposit (Phase 1)Intent expiredCreate a new intent
Deposit (Phase 1)Deposit address generation failedRetry — endpoint is idempotent
WaitingDeposit not detectedEnsure correct chain and address; check with block explorer
Payout previewDeposit not confirmedWait longer for blockchain confirmation
Payout (Phase 2)FX quotation failedRetry — the system will obtain a new quote
Payout (Phase 2)Insufficient amount (fees > value)Customer sent too little crypto

Next Steps