> For the complete documentation index, see [llms.txt](https://ivorypay.gitbook.io/ivorypay-on-off-ramp-documentation/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://ivorypay.gitbook.io/ivorypay-on-off-ramp-documentation/guides/off-ramp.md).

# Off-Ramp: Crypto → Fiat

Step-by-step guide to integrating crypto-to-fiat off-ramp transactions.

The off-ramp flow lets your users send cryptocurrency and receive fiat payouts to their bank account or mobile money wallet. IvoryPay handles the crypto collection, conversion, and payout.

## How it works

```
1. Your server calls POST /v1/offramp with user, account, and transaction details
2. IvoryPay returns a crypto wallet address and amount for the user to send to
3. The user sends crypto to the provided address
4. IvoryPay detects the deposit → webhook: offramp.cryptoPaymentReceived
5. IvoryPay converts crypto to fiat and initiates the payout
6. Payout completes → webhook: offramp.success (or offramp.failed)
```

## Mobile money support

For currencies other than NGN (e.g. UGX, KES), payouts are delivered via mobile money. Instead of a bank account, provide:

* `accountNumber` — the recipient's mobile phone number (digits only, with country code)
* `bankCode` — not required for mobile money payouts

Bank account resolution (Step 1 below) is not required for mobile money — you can skip directly to creating the transaction.

## Integration steps

{% stepper %}
{% step %}

### Resolve the user's bank account (bank payouts only)

For NGN bank payouts, verify the user's bank details before initiating:

```bash
curl -X POST https://ramp-api.ivorypay.io/api/v1/banks/account-resolution \
  -H "Authorization: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "accountNumber": "0123456789",
    "bankCode": "058",
    "currency": "NGN"
  }'
```

This returns the account holder's name, which you should confirm with your user before proceeding.

{% hint style="info" %}
For mobile money payouts (non-NGN currencies), skip this step — account resolution is not required.
{% endhint %}
{% endstep %}

{% step %}

### Create the off-ramp transaction

#### Bank payout (NGN)

```bash
curl -X POST https://ramp-api.ivorypay.io/api/v1/offramp \
  -H "Authorization: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Chidi",
    "lastName": "Nwankwo",
    "email": "chidi@example.com",
    "phoneNumber": "+2348098765432",
    "accountNumber": "0123456789",
    "bankCode": "058",
    "blockchain": "BSC_MAINNET",
    "reference": "660e8400-e29b-41d4-a716-446655440001",
    "token": "USDT",
    "fiatCurrency": "NGN",
    "fiatAmount": 100000,
    "businessFeeInFiat": 0
  }'
```

#### Mobile money payout (UGX)

```bash
curl -X POST https://ramp-api.ivorypay.io/api/v1/offramp \
  -H "Authorization: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Chidi",
    "lastName": "Nwankwo",
    "email": "chidi@example.com",
    "phoneNumber": "+256772882926",
    "accountNumber": "256772882926",
    "blockchain": "STARKNET_MAINNET",
    "reference": "660e8410-f23c-41c4-a628-838625530891",
    "token": "USDT",
    "fiatCurrency": "UGX",
    "cryptoAmount": 1.5,
    "businessFeeInCrypto": 0.001
  }'
```

#### Response

The endpoint returns HTTP `201` with the standard envelope.

```json
{
  "success": true,
  "message": "Query successful",
  "data": {
    "firstName": "Chidi",
    "lastName": "Nwankwo",
    "email": "chidi@example.com",
    "refCode": "xyz789abc0",
    "reference": "660e8400-e29b-41d4-a716-446655440001",
    "transferDetails": {
      "token": "USDT",
      "blockchain": "BSC_MAINNET",
      "amountPayable": 62.5,
      "businessFee": 0,
      "platformFee": 0.3,
      "walletAddress": "0xIvoryPayDepositAddress...",
      "createdAt": "2026-03-26T12:00:00.000Z",
      "fiatAmount": 100000,
      "expiresAt": "2026-03-26T13:00:00.000Z",
      "sessionId": "session_abc123",
      "duration": 3600
    }
  }
}
```

{% endstep %}

{% step %}

### User sends crypto

Display the `walletAddress`, `amountPayable` (in crypto), and `blockchain` to your user. They send the specified amount of crypto to the wallet address.

{% hint style="warning" %}
The session expires at `expiresAt`. If the crypto isn't received before expiry, use the [Refresh Session](/ivorypay-on-off-ramp-documentation/api-reference-off-ramp/refresh-session.md) endpoint to extend it.
{% endhint %}

{% hint style="info" %}
**Testing in TEST mode?** Off-ramps are completed by an actual on-chain deposit, not a simulation call (`transactions/simulate-payment` only applies to on-ramps). Grab free testnet/devnet crypto from the [IvoryPay faucet](https://www.ivorypay.io/get-tokens) and send it to the `walletAddress` to trigger the deposit webhooks.
{% endhint %}
{% endstep %}

{% step %}

### Listen for webhooks

| Event                           | When it fires                                                        |
| ------------------------------- | -------------------------------------------------------------------- |
| `offramp.cryptoPaymentReceived` | Crypto deposit detected on-chain                                     |
| `offramp.success`               | Fiat payout has been sent to the bank account or mobile money wallet |
| `offramp.failed`                | Payout failed — check `failedReason`                                 |
| `offramp.declined`              | Transaction was declined — check `reasonForDecline`                  |
| {% endstep %}                   |                                                                      |

{% step %}

### Confirm crypto sent (optional)

If the user has sent crypto outside of your app (e.g., from an external wallet), you can manually confirm the payment:

```bash
curl -X POST https://ramp-api.ivorypay.io/api/v1/offramp/crypto-sent \
  -H "Authorization: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "reference": "660e8400-e29b-41d4-a716-446655440001",
    "settledCryptoAmount": 62.5,
    "transactionHash": "0xdef456..."
  }'
```

{% endstep %}
{% endstepper %}

## Specifying amounts and fees

Business fees **must be supplied in the same denomination as the amount**. There are two valid patterns:

| Scenario                            | Fields to send                                        |
| ----------------------------------- | ----------------------------------------------------- |
| User wants to receive 100,000 NGN   | `"fiatAmount": 100000`, `"businessFeeInFiat": 250`    |
| User wants to send exactly 1.5 USDT | `"cryptoAmount": 1.5`, `"businessFeeInCrypto": 0.001` |

{% hint style="danger" %}
Mixing denominations (e.g. `fiatAmount` + `businessFeeInCrypto`, or `cryptoAmount` + `businessFeeInFiat`) is rejected by validation (`FiatOrCryptoAmountConstraint`).
{% endhint %}

## Transaction lifecycle

```
INITIATED → PENDING → CRYPTO_PAYMENT_RECEIVED → FIAT_PAYOUT_PROCESSING → SUCCESS
                                                                        → FAILED
                                                                        → DECLINED
            → EXPIRED (if user doesn't send crypto in time)
```

## Frequently asked questions

<details>

<summary>What happens if the user sends the wrong cryptocurrency or token?</summary>

If the user sends an unsupported token or a different token than the one specified in the transaction (e.g., USDC instead of USDT), IvoryPay will not detect it as a valid payment. The transaction will eventually expire. You should clearly display the required `token` and `blockchain` to the user before they send funds. If this happens, contact IvoryPay support for assistance recovering the funds.

</details>

<details>

<summary>What happens if the off-ramp session expires?</summary>

If the user does not send crypto before the `expiresAt` time, the session expires and the deposit address is no longer monitored. You can use the [Refresh Session](/ivorypay-on-off-ramp-documentation/api-reference-off-ramp/refresh-session.md) endpoint to extend it. If the session has already fully expired, you will need to create a new off-ramp transaction.

</details>

<details>

<summary>What if the user sends a different amount of crypto than expected?</summary>

If the user sends more or less crypto than the `amountPayable`, IvoryPay will process based on the amount actually received on-chain. The fiat payout will be recalculated accordingly. Check the webhook payload for the final settled amounts.

</details>

<details>

<summary>Why was my off-ramp transaction declined?</summary>

A `offramp.declined` event means IvoryPay could not process the payout. The `reasonForDecline` field in the webhook payload will explain why. Common reasons include invalid account details, compliance checks, or insufficient liquidity. Resolve the issue and create a new transaction.

</details>

<details>

<summary>Do I need to call the crypto-sent endpoint if the user pays from within my app?</summary>

No. The `POST /v1/offramp/crypto-sent` endpoint is only needed when the user sends crypto from an external wallet outside your application. If your app initiates the transfer directly, IvoryPay will detect the on-chain payment automatically.

</details>
