<!-- ZenPays documentation · https://docs.zenpayz.com/docs/examples/on-ramp-flow -->

# On-Ramp (Buy) Flow

This guide walks through the complete on-ramp integration — allowing your customers to buy crypto with fiat. The flow uses **ramp intents** to track the entire lifecycle from creation to completion.

## Flow Overview

<Diagram
  slug="on-ramp-flow"
  alt="Two swimlanes. On your server, step 1 POSTs to /ramp-intents and receives an intentId and redirectUrl; the customer is redirected to your frontend or the widget. There, step 2 GETs /ramp-intents/:id for the intent details and widgetUrl, step 3 either embeds that widgetUrl or calls the API directly, step 4 is the customer completing payment, and step 5 GETs /on-ramp/transactions/:id until it reports completed. A panel below expands step 3 into the two calls the widget makes for you: 3a GET /on-ramp/quotes returns pricing options, and 3b POST /on-ramp/checkout returns a transactionId and redirectUrl."
  caption="Buying crypto: intent on your server, purchase in your frontend"
/>

## Step 1: Create a Ramp Intent (Server-Side)

Create a ramp intent from your backend. This requires your API key and merchant ID.

<Tabs groupId="language">
  <TabItem value="javascript" label="JavaScript" default>

```javascript
// Server-side — requires API key
const createBuyIntent = 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: 'buy',
      wallets: [{ network: 'ethereum', address: walletAddress }],
      fiatCurrency: 'USD',
      cryptoCurrency: 'eth_ethereum',
      amount: 100,
      customerEmail,
      successUrl: 'https://yourapp.com/buy/success',
      cancelUrl: 'https://yourapp.com/buy/cancel',
    }),
  });

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

  </TabItem>
  <TabItem value="python" label="Python">

```python
import requests
import os
import time

def create_buy_intent(customer_email: str, wallet_address: str):
    response = requests.post(
        "https://api.zenpayz.com/payment/api/v1/ramp-intents",
        headers={
            "Authorization": f"Bearer {os.environ['ZENPAYS_API_KEY']}",
            "x-merchant-id": os.environ["ZENPAYS_MERCHANT_ID"],
            "x-request-id": f"req_{int(time.time())}",
        },
        json={
            "type": "buy",
            "wallets": [{"network": "ethereum", "address": wallet_address}],
            "fiatCurrency": "USD",
            "cryptoCurrency": "eth_ethereum",
            "amount": 100,
            "customerEmail": customer_email,
            "successUrl": "https://yourapp.com/buy/success",
            "cancelUrl": "https://yourapp.com/buy/cancel",
        },
    )
    return response.json()["data"]
```

  </TabItem>
</Tabs>

## Step 2: Redirect Customer to Widget

Send the customer to the `redirectUrl` from Step 1, or fetch the intent on your frontend to get the widget URL for iframe embedding.

<Tabs groupId="language">
  <TabItem value="javascript" label="JavaScript" default>

```javascript
// Option A: Simple redirect
window.location.href = intentData.redirectUrl;

// Option B: Fetch intent and embed widget in iframe
const fetchAndEmbed = async (intentId) => {
  const response = await fetch(
    `https://api.zenpayz.com/payment/api/v1/ramp-intents/${intentId}`
  );
  const { data } = await response.json();

  // Terminal lifecycle states
  if (data.intent.status === 'expired') {
    showError('This session has expired. Please start again.');
    return;
  }
  if (data.intent.status === 'cancelled') {
    // When KYC is declined the intent transitions to cancelled with
    // cancelReason='kyc_rejected'. See get-ramp-intent for details.
    showError(
      data.intent.cancelReason === 'kyc_rejected'
        ? 'Identity verification could not be completed.'
        : 'This session has been cancelled.'
    );
    return;
  }

  // KYC gate — surface a friendly state-specific message
  switch (data.intent.kycStatus) {
    case 'in_review':
      showInfo('Verification under review — we\'ll continue automatically once approved.');
      return;
    case 'pending':
      // Customer needs to start / finish verification. If `kycRequired` is
      // present in the response and `kycVerificationUrl` is set, embed it;
      // otherwise call initiate-kyc to create a session.
      break;
  }

  if (data.widgetUrl) {
    const iframe = document.createElement('iframe');
    iframe.src = data.widgetUrl;
    iframe.style.cssText = 'width:100%;height:600px;border:none;border-radius:12px;';
    iframe.allow = 'payment';
    document.getElementById('widget-container').appendChild(iframe);
  }
};
```

  </TabItem>
  <TabItem value="python" label="Python">

```python
# Server-side: return the redirect URL to your frontend
def get_intent_widget(intent_id: str):
    response = requests.get(
        f"https://api.zenpayz.com/payment/api/v1/ramp-intents/{intent_id}"
    )
    data = response.json()["data"]

    if data["intent"]["status"] == "expired":
        raise Exception("Intent expired")

    return {
        "intent": data["intent"],
        "widgetUrl": data["widgetUrl"],
    }
```

  </TabItem>
</Tabs>

## Step 3: Get Quotes (API Mode)

If you're building a custom UI instead of using the widget, fetch buy quotes to show pricing options.

<Tabs groupId="language">
  <TabItem value="javascript" label="JavaScript" default>

```javascript
const getQuotes = async (source, destination, amount) => {
  const params = new URLSearchParams({ source, destination, amount: String(amount) });

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

  // data.quotes = array of quote options
  data.quotes.forEach((quote) => {
    console.log(`${quote.onramp}: ${quote.outputAmount} crypto`);
    console.log(`  Rate: ${quote.rate}, Fee: ${quote.totalFee}`);
    console.log(`  Payment: ${quote.paymentMethod}`);
    if (quote.recommended) console.log('  ⭐ Recommended');
  });

  return data.quotes;
};

const quotes = await getQuotes('usd', 'eth_ethereum', 100);
```

  </TabItem>
  <TabItem value="python" label="Python">

```python
def get_quotes(source: str, destination: str, amount: float):
    response = requests.get(
        "https://api.zenpayz.com/payment/api/v1/on-ramp/quotes",
        params={
            "source": source,
            "destination": destination,
            "amount": amount,
        },
    )
    quotes = response.json()["data"]["quotes"]

    for quote in quotes:
        print(f"{quote['onramp']}: {quote['outputAmount']} crypto")
        print(f"  Rate: {quote['rate']}, Fee: {quote['totalFee']}")
    return quotes
```

  </TabItem>
</Tabs>

## Step 4: Create Checkout

After the customer selects a quote, create a checkout to start the payment.

<Tabs groupId="language">
  <TabItem value="javascript" label="JavaScript" default>

```javascript
const createCheckout = async (selectedQuote, walletAddress) => {
  const response = await fetch(
    'https://api.zenpayz.com/payment/api/v1/on-ramp/checkout',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        onramp: selectedQuote.onramp,
        source: 'usd',
        destination: 'eth_ethereum',
        amount: 100,
        type: 'buy',
        paymentMethod: selectedQuote.paymentMethod,
        walletAddress,
      }),
    }
  );

  const { data } = await response.json();
  // Redirect to payment page
  window.location.href = data.redirectUrl;
};
```

  </TabItem>
  <TabItem value="python" label="Python">

```python
def create_checkout(selected_quote, wallet_address: str):
    response = requests.post(
        "https://api.zenpayz.com/payment/api/v1/on-ramp/checkout",
        json={
            "onramp": selected_quote["onramp"],
            "source": "usd",
            "destination": "eth_ethereum",
            "amount": 100,
            "type": "buy",
            "paymentMethod": selected_quote["paymentMethod"],
            "walletAddress": wallet_address,
        },
    )
    return response.json()["data"]
```

  </TabItem>
</Tabs>

## Step 5: Poll Transaction Status

After the customer completes payment, poll the transaction status until it reaches a terminal state.

<Tabs groupId="language">
  <TabItem value="javascript" label="JavaScript" default>

```javascript
const pollTransaction = async (transactionId) => {
  const terminalStatuses = ['completed', 'failed', 'expired', 'refunded'];

  while (true) {
    const response = await fetch(
      `https://api.zenpayz.com/payment/api/v1/on-ramp/transactions/${transactionId}`
    );
    const { data } = await response.json();

    console.log(`Status: ${data.status}`);

    if (terminalStatuses.includes(data.status)) {
      return data;
    }

    // Wait 5 seconds before next poll
    await new Promise((resolve) => setTimeout(resolve, 5000));
  }
};

const result = await pollTransaction('txn_abc123');

if (result.status === 'completed') {
  console.log(`Success! ${result.outAmount} crypto delivered`);
  console.log(`TX Hash: ${result.transactionHash}`);
} else {
  console.log(`Transaction ${result.status}`);
}
```

  </TabItem>
  <TabItem value="python" label="Python">

```python
import time

def poll_transaction(transaction_id: str):
    terminal = {"completed", "failed", "expired", "refunded"}

    while True:
        response = requests.get(
            f"https://api.zenpayz.com/payment/api/v1/on-ramp/transactions/{transaction_id}"
        )
        data = response.json()["data"]
        print(f"Status: {data['status']}")

        if data["status"] in terminal:
            return data

        time.sleep(5)

result = poll_transaction("txn_abc123")
if result["status"] == "completed":
    print(f"Success! {result['outAmount']} crypto delivered")
```

  </TabItem>
</Tabs>

## Step 6: Receive Webhook Notification

Instead of polling (or in addition to it), you can receive a webhook when the ramp completes or fails. Configure your webhook URL in the merchant dashboard.

**`ramp.completed`** — crypto has been sent to the customer's wallet:

```json
{
  "event_type": "ramp.completed",
  "payment_data": {
    "merchant_id": "m_abc123",
    "intentId": "ri_1710345678000_a1b2c3d4e5f6g7h8",
    "type": "buy",
    "status": "completed",
    "fiatCurrency": "USD",
    "cryptoCurrency": "eth_ethereum",
    "amount": 100,
    "chain": "ethereum",
    "destinationAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28",
    "fireblocksTxId": "fb_tx_9a8b7c6d5e4f",
    "completedAt": "2026-03-16T14:30:00.000Z"
  }
}
```

**`ramp.failed`** — crypto send failed:

```json
{
  "event_type": "ramp.failed",
  "payment_data": {
    "merchant_id": "m_abc123",
    "intentId": "ri_1710345678000_a1b2c3d4e5f6g7h8",
    "type": "buy",
    "status": "failed",
    "fiatCurrency": "USD",
    "cryptoCurrency": "eth_ethereum",
    "amount": 100,
    "reason": "Crypto send failed",
    "failedAt": "2026-03-16T14:30:00.000Z"
  }
}
```

:::tip Webhook vs Polling
We recommend using webhooks as the primary notification mechanism and polling as a fallback. Webhooks are delivered as soon as the status changes — no delay. See the [Webhooks guide](/docs/guides/webhooks) for setup instructions, signature verification, and retry behavior.
:::

## Intent Status Lifecycle

```
created  →  active  →  completed
                   →  cancelled
                   →  expired (auto, after 1 hour)
```

| Status | Description |
|--------|-------------|
| `created` | Intent created, not yet accessed |
| `active` | Customer has accessed the intent (first GET) |
| `completed` | Transaction completed successfully |
| `cancelled` | Manually cancelled by merchant or customer |
| `expired` | Auto-expired after 1 hour |

## Error Handling

- **Intent expired**: Create a new intent and redirect the customer
- **Quote expired**: Fetch fresh quotes — prices change frequently
- **Payment failed**: Check the transaction status for details; the customer can retry
- **Network errors**: Implement retry with exponential backoff

## Next Steps

- [Create Ramp Intent](/docs/rest-api/endpoints/ramp/create-ramp-intent) — API reference
- [Buy Quotes](/docs/rest-api/endpoints/ramp/buy-quotes) — Quote endpoint details
- [Buy Checkout](/docs/rest-api/endpoints/ramp/buy-checkout) — Checkout endpoint details
- [Transaction Status](/docs/rest-api/endpoints/ramp/transaction-status) — Status polling details
- [Off-Ramp Flow](/docs/examples/off-ramp-flow) — Sell crypto integration guide
