> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tender.cash/llms.txt
> Use this file to discover all available pages before exploring further.

# On-ramp

> Let customers pay with local fiat and receive cryptocurrency directly to their wallet

The Tender on-ramp converts a fiat bank payment (e.g. NGN transfer) into cryptocurrency and
delivers it to a wallet address on the customer's chosen blockchain — all in a single integration.

## How it works

<Steps>
  <Step title="Get a quote">
    Call `POST /onramp/quote` with the fiat amount and target cryptocurrency. Tender returns a
    live exchange rate, an estimated crypto output, and a `quoteId` that is valid for a short
    window (typically 60 seconds).
  </Step>

  <Step title="Initiate the on-ramp">
    Call `POST /onramp/initiate` with the `quoteId`, the customer's wallet address, and their
    contact details. Tender creates a virtual bank account and returns the transfer details to
    show your customer.
  </Step>

  <Step title="Customer pays">
    Customer makes a local bank transfer to the virtual account. No crypto wallet or exchange
    account is needed on their side — just a standard bank transfer.
  </Step>

  <Step title="Tender processes automatically">
    Once payment is confirmed, Tender runs the pipeline automatically:

    * Forwards the fiat to the swap provider
    * Swaps fiat → intermediate crypto (USDT on Tron)
    * Delivers the final crypto to the `targetAddress` on the `targetChain`
  </Step>

  <Step title="Poll for completion">
    Call `GET /onramp/{reference}` to track progress. The `stages` object shows exactly where
    the pipeline is at any moment.
  </Step>
</Steps>

***

## Pipeline stages

After payment is confirmed, the on-ramp moves through these stages in order:

| Stage          | What happens                                                            |
| -------------- | ----------------------------------------------------------------------- |
| `payment`      | Fiat payment confirmed by the payment provider                          |
| `payout`       | Fiat forwarded to the swap provider's account                           |
| `swap`         | Fiat swapped to intermediate crypto (e.g. USDT on Tron)                 |
| `swapWithdraw` | Swapped crypto withdrawn to Tender's swap wallet *(skipped by default)* |
| `cryptoSend`   | Crypto sent from Tender's swap wallet to the customer's `targetAddress` |

Each stage has a `status` of `pending`, `in_progress`, `completed`, `failed`, or `skipped`.

***

## Overall status values

| Status            | Meaning                                                          |
| ----------------- | ---------------------------------------------------------------- |
| `pending_payment` | Waiting for the customer's bank transfer                         |
| `processing`      | Payment confirmed; pipeline is running                           |
| `crypto_sent`     | Crypto dispatched to `targetAddress`                             |
| `completed`       | Fully settled                                                    |
| `failed`          | Terminal failure — see `failureReason` and `stages[*].lastError` |

***

## Key concepts

### Quotes are required and single-use

Before initiating, you must call `/onramp/quote` to lock in a rate. The returned `quoteId` is:

* **Time-limited** — expires after `time_to_lock` seconds (shown as `expiresAt`)
* **Single-use** — consumed the moment it is passed to `/onramp/initiate`

Attempting to initiate with an expired or already-used quote returns a `400` error.

### Virtual bank accounts expire

The virtual bank account returned by `/onramp/initiate` expires after **1 hour**. If the customer
does not pay within that window, the on-ramp request moves to `failed` status.

### Supported chains and currencies

Use the discovery endpoints to present your customers with what is available:

* `GET /onramp/chains` — chains that support on-ramp delivery
* `GET /onramp/coins` — cryptocurrencies available, optionally filtered by chain

***

## Integration

This section walks through every step of a real on-ramp integration, from discovery to polling for
completion. All examples use the sandbox environment.

### Prerequisites

* A Tender merchant account with API credentials
* Your `TENDER_ACCESS_ID` and `TENDER_ACCESS_SECRET` set as environment variables

<Card title="Authentication guide" icon="lock" href="/api-reference/authentication">
  Onramp endpoints use Signed (HMAC) authentication — an HMAC-SHA256 signature is required on every request. Follow the authentication guide for full details
  and code examples in Node.js, Python, and PHP before continuing.
</Card>

The examples below assume you have a `makeHeaders()` helper and a `BASE` constant as described in
that guide:

```javascript theme={null}
const BASE = 'https://sandbox-api.tender.cash/v1/api';
// makeHeaders() → { 'x-access-id', 'x-request-id', 'x-timestamp', 'authorization', ... }
```

***

### Step 1 — Discover available chains and coins

Show your customers which blockchains and currencies they can receive.

<CodeGroup>
  ```javascript Node.js theme={null}
  // Fetch supported chains
  const chainsRes = await fetch(`${BASE}/onramp/chains`, {
    headers: makeHeaders(),
  });
  const { data: chains } = await chainsRes.json();
  // chains = [{ id: "tron", name: "Tron", icon: "...", ... }]

  // Fetch supported coins (optionally filter by chain)
  const coinsRes = await fetch(`${BASE}/onramp/coins?chain=tron`, {
    headers: makeHeaders(),
  });
  const { data: coins } = await coinsRes.json();
  // coins = [{ id: "usdt", name: "Tether USD", symbol: "USDT", ... }]
  ```

  ```python Python theme={null}
  import requests

  chains_res = requests.get(f"{BASE}/onramp/chains", headers=make_headers())
  chains = chains_res.json()["data"]

  coins_res = requests.get(f"{BASE}/onramp/coins?chain=tron", headers=make_headers())
  coins = coins_res.json()["data"]
  ```
</CodeGroup>

***

### Step 2 — Get a quote

Once the customer has chosen a target currency and entered their fiat amount, fetch a live quote.

<CodeGroup>
  ```javascript Node.js theme={null}
  const quoteRes = await fetch(`${BASE}/onramp/quote`, {
    method: 'POST',
    headers: makeHeaders(),
    body: JSON.stringify({
      fiatAmount:     50000,      // NGN, in whole units (not kobo)
      fiatCurrency:   'NGN',      // defaults to NGN if omitted
      targetCurrency: 'USDT',
      targetChain:    'tron',
    }),
  });

  const { data: quote } = await quoteRes.json();
  console.log(quote);
  /*
  {
    quoteId:               "b7f2a1c3-9d4e-4b2f-a8c0-1e2d3f4a5b6c",
    fiatCurrency:          "NGN",
    fiatAmount:            50000,
    targetCurrency:        "USDT",
    targetChain:           "tron",
    rate:                  0.000597,
    estimatedCryptoAmount: "29.850000",
    expiresAt:             "2025-06-10T10:05:00.000Z"
  }
  */
  ```

  ```python Python theme={null}
  quote_res = requests.post(
      f"{BASE}/onramp/quote",
      headers=make_headers(),
      json={
          "fiatAmount":     50000,
          "fiatCurrency":   "NGN",
          "targetCurrency": "USDT",
          "targetChain":    "tron",
      },
  )
  quote = quote_res.json()["data"]
  ```
</CodeGroup>

<Warning>
  Quotes expire at `expiresAt`. Display a countdown and call `/onramp/quote` again if the customer
  lets it lapse before confirming.
</Warning>

***

### Step 3 — Show the quote to your customer

Display the estimated output and ask for confirmation before initiating.

```
You pay:        ₦50,000 NGN
You receive:  ≈ 29.85 USDT
Network:         Tron (TRC-20)
Rate expires:    in 58 seconds
```

<Tip>
  The estimated amount is based on the rate at quote time. The actual amount delivered may differ
  slightly if the quote expires and a fresh rate is applied internally.
</Tip>

***

### Step 4 — Initiate the on-ramp

After the customer confirms, call `/onramp/initiate` with the `quoteId` and their wallet address.

<CodeGroup>
  ```javascript Node.js theme={null}
  const initiateRes = await fetch(`${BASE}/onramp/initiate`, {
    method: 'POST',
    headers: makeHeaders(),
    body: JSON.stringify({
      quoteId:       quote.quoteId,
      targetAddress: 'TRDFGhjkytywooiueonuoo',  // customer's USDT/Tron address
      customer: {
        email: 'customer@example.com',
        name:  'Ada Obi',
      },
      metadata: { orderId: 'ORD-9821' },  // your own reference, optional
    }),
  });

  const { data: onramp } = await initiateRes.json();
  console.log(onramp);
  /*
  {
    reference:   "a3f1c2d4-8e7b-4f0a-9c1d-2e3f4a5b6c7d",
    status:      "pending_payment",
    bankTransfer: {
      accountNumber: "0123456789",
      accountName:   "Tender / Ada Obi",
      bankName:      "Wema Bank"
    },
    expiresAt: "2025-06-10T11:00:00.000Z",
    amount:    { value: 50000, currency: "NGN" }
  }
  */
  ```

  ```python Python theme={null}
  initiate_res = requests.post(
      f"{BASE}/onramp/initiate",
      headers=make_headers(),
      json={
          "quoteId":       quote["quoteId"],
          "targetAddress": "TRDFGhjkytywooiueonuoo",
          "customer": {
              "email": "customer@example.com",
              "name":  "Ada Obi",
          },
      },
  )
  onramp = initiate_res.json()["data"]
  reference = onramp["reference"]
  ```
</CodeGroup>

**Error cases to handle:**

| Error message                       | Cause                                                   | Fix                  |
| ----------------------------------- | ------------------------------------------------------- | -------------------- |
| `"Quote not found or already used"` | `quoteId` was already consumed or never existed         | Fetch a fresh quote  |
| `"Quote has expired"`               | `expiresAt` passed before `/initiate` was called        | Fetch a fresh quote  |
| Joi validation error                | `quoteId` missing, not a UUID, or `targetAddress` empty | Fix the request body |

***

### Step 5 — Display bank transfer details

Show the returned `bankTransfer` details so the customer knows where to send money.

```
Please transfer ₦50,000 to:

  Bank:           Wema Bank
  Account name:   Tender / Ada Obi
  Account number: 0123456789

  This account expires at 11:00 AM UTC.
  Do not send a different amount.
```

Store the `reference` — you will use it to poll for status.

***

### Step 6 — Poll for completion

Poll `GET /onramp/{reference}` until `status` is `crypto_sent` or `completed`.

<CodeGroup>
  ```javascript Node.js theme={null}
  async function pollOnramp(reference, intervalMs = 10000) {
    while (true) {
      const res  = await fetch(`${BASE}/onramp/${reference}`, { headers: makeHeaders() });
      const { data } = await res.json();

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

      if (data.status === 'crypto_sent' || data.status === 'completed') {
        console.log('On-ramp complete!');
        return data;
      }

      if (data.status === 'failed') {
        throw new Error(`On-ramp failed: ${data.failureReason}`);
      }

      await new Promise(r => setTimeout(r, intervalMs));
    }
  }

  const result = await pollOnramp(onramp.reference);
  ```

  ```python Python theme={null}
  import time

  def poll_onramp(reference, interval_s=10):
      while True:
          res  = requests.get(f"{BASE}/onramp/{reference}", headers=make_headers())
          data = res.json()["data"]

          print(f"Status: {data['status']}")

          if data["status"] in ("crypto_sent", "completed"):
              print("On-ramp complete!")
              return data

          if data["status"] == "failed":
              raise RuntimeError(f"On-ramp failed: {data.get('failureReason')}")

          time.sleep(interval_s)

  result = poll_onramp(reference)
  ```
</CodeGroup>

<Tip>
  Rather than polling, configure a webhook to receive a notification when the status changes.
  See [Webhooks](/get-started/webhooks) for setup instructions.
</Tip>

***

### Inspecting stage progress

The `stages` object in the status response shows the exact state of each pipeline step.

```json theme={null}
{
  "stages": {
    "payment":      { "status": "completed",   "completedAt": "2025-06-10T10:02:30Z" },
    "payout":       { "status": "completed",   "completedAt": "2025-06-10T10:03:10Z" },
    "swap":         { "status": "in_progress", "startedAt":   "2025-06-10T10:03:15Z", "attempts": 1 },
    "swapWithdraw": { "status": "skipped" },
    "cryptoSend":   { "status": "pending" }
  }
}
```

***

### Error handling

| Scenario                                 | Recommended action                                                  |
| ---------------------------------------- | ------------------------------------------------------------------- |
| `status: "failed"`                       | Read `failureReason` and display it; offer the customer a retry     |
| `status: "pending_payment"` after 1 hour | The virtual account expired; start a new on-ramp                    |
| Stage `status: "failed"`                 | Read `stages[*].lastError` for the provider-level reason            |
| Network error polling                    | Retry with exponential backoff; the on-ramp reference is idempotent |
