This guide walks through integrating crypto conversion flows via the PCX API. PCX acts as the orchestrator — your users send or receive fiat, and PCX handles the crypto side and settlement automatically.
Two directions are supported:
- On-ramp — customer pays in fiat, receives crypto directly to their wallet
- Off-ramp — customer sends crypto, receives fiat to a bank or mobile money account
Prerequisites
Before initiating a ramp, your integration must have:
- A valid API key — see Authentication
- An approved organisation (
org_id)
- A registered beneficiary (
beneficiary_id) for off-ramp flows — see Beneficiaries
The ramp endpoint uses API key authentication. Pass your key in the X-Api-Key header.
Step 1 — Fetch the exchange rate
Fetch the current rate for the corridor before initiating the ramp. This gives you a rate to present to the user and an org_rate_id that you must pass in the ramp request.
GET /organizations/admin/exchange-rate?orgId={org_id}&fromCurrency={from_currency}&toCurrency={to_currency}
The direction of the corridor — which currency to send as fromCurrency vs toCurrency — depends on the ramp direction you intend to initiate. The rate must be fetched in the same direction the funds flow:
| Ramp direction | fromCurrency | toCurrency |
|---|
on_ramp (fiat → crypto) | Fiat currency (e.g. NGN) | Crypto’s fiat equivalent (e.g. USD) |
off_ramp (crypto → fiat) | Crypto’s fiat equivalent (e.g. USD) | Fiat currency (e.g. NGN) |
The crypto’s fiat equivalent is mapped as follows:
| Crypto asset | Fiat equivalent |
|---|
USDC | USD |
USDT | USD |
EURC | EUR |
BTC, ETH, etc. | Use the fiat currency directly |
If you fetch the rate in the wrong direction for the ramp you are initiating, the request fails with RATE_STALE. Always align the rate fetch direction with the ramp direction.
Example — on-ramp NGN → USDC:
GET /organizations/admin/exchange-rate?orgId=k257f15d-3a59-4b9f-afdc-087fc2903eer&fromCurrency=NGN&toCurrency=USD
Example — off-ramp USDC → NGN:
GET /organizations/admin/exchange-rate?orgId=k257f15d-3a59-4b9f-afdc-087fc2903eer&fromCurrency=USD&toCurrency=NGN
Response:
{
"org_rate_id": "258b95aa-a76f-4144-ade1-23814a32900d",
"from_currency": "NGN",
"to_currency": "USD",
"rate": 0.00000645,
"expires_at": "2026-06-04T12:00:00.000Z"
}
Store the rate (to show to your user as client_rate) and the org_rate_id. Both are required in the ramp request. Rates are spread-adjusted and validated against the live market on every fetch — always use the latest value before presenting it to a user.
Step 2 — Initiate the ramp
On-ramp — fiat → crypto
The customer pays in fiat. PCX settles the crypto directly to the wallet_address you provide. No further action is needed on your end after a successful initiation.
POST /payments-init/ramp
X-Api-Key: {your-api-key}
Example — NGN mobile money payin → USDC on Ethereum:
{
"direction": "on_ramp",
"user_id": "16a20294-a031-700c-e47a-74613d6596cb",
"org_id": "k257f15d-3a59-4b9f-afdc-087fc2903eer",
"fiat_currency": "NGN",
"fiat_amount": 10000.00,
"client_rate": 0.00000645,
"crypto_currency": "USDC",
"crypto_network": "ethereum",
"payment_method": "mobile_money",
"country": "NG",
"payer_details": {
"name": "Jane Kamau",
"email": "jane@example.com",
"phone": "+2348122603628"
},
"wallet_address": "0xABC123...your-wallet-address",
"mobile_money_details": {
"provider": "MTN",
"phone_number": "+2348122603628"
},
"metadata": {
"reason": "crypto_purchase"
}
}
PCX handles the crypto payout directly. Once the fiat collection is confirmed, the crypto is settled to wallet_address automatically — you do not need to initiate a separate payout.
Off-ramp — crypto → fiat
The customer sends crypto; PCX arranges for fiat to be paid out to their bank or mobile money account. The beneficiary must be registered before initiating.
Example — USDC → NGN bank payout:
{
"direction": "off_ramp",
"user_id": "16a20294-a031-700c-e47a-74613d6596cb",
"org_id": "k257f15d-3a59-4b9f-afdc-087fc2903eer",
"fiat_currency": "NGN",
"fiat_amount": 10000.00,
"client_rate": 0.00000645,
"crypto_currency": "USDC",
"crypto_network": "ethereum",
"payment_method": "bank_transfer",
"country": "NG",
"payer_details": {
"name": "John Doe",
"email": "john@example.com",
"phone": "+2348000000000"
},
"beneficiary_id": "a07f1e7e-6546-4143-993e-2c9f07151634",
"beneficiary_name": "John Doe",
"destination": {
"accountNumber": "0123456789",
"bankCode": "058",
"bankName": "GTBank"
},
"metadata": {
"reason": "crypto_sale"
}
}
Step 3 — Handle the response
A successful ramp initiation returns 200. The response now includes next_action and payment_instructions so you can drive the customer to the right next step without a second API call.
On-ramp — bank transfer (next_action: make_transfer):
{
"statusCode": 200,
"response": {
"success": true,
"payment_id": "ea5965e4-1432-40ef-bccf-cb51455a935a",
"transaction_id": "2a679c96-69bf-424c-89cb-c2f4a90a2738",
"direction": "on_ramp",
"status": "pending",
"fiat_currency": "NGN",
"fiat_amount": 10000.00,
"crypto_currency": "USDC",
"crypto_network": "ethereum",
"crypto_amount": 6.45,
"expires_at": "2026-06-04T11:34:34.128120Z",
"next_action": "make_transfer",
"payment_instructions": {
"type": "bank_transfer",
"account_name": "PCX Collections",
"account_number": "0123456789",
"bank_name": "GTBank",
"amount": 10000.00,
"currency": "NGN",
"reference": "PCX-ea5965e4"
}
}
}
On-ramp — mobile money (next_action: approve_prompt):
{
"next_action": "approve_prompt",
"payment_instructions": {
"type": "mobile_money",
"message": "An M-PESA STK push has been sent to +254700000000. Approve on your phone to complete payment.",
"amount": 10000.00,
"currency": "KES"
}
}
Off-ramp (next_action: send_crypto):
{
"next_action": "send_crypto",
"payment_instructions": {
"type": "crypto_transfer",
"wallet_address": "0xDEF456...pcx-deposit-address",
"crypto_amount": 6.45,
"crypto_currency": "USDC",
"crypto_network": "ethereum",
"expires_at": "2026-06-04T11:34:34.128120Z"
}
}
Send the exact crypto_amount to wallet_address on crypto_network before expires_at. Once the deposit is confirmed on-chain, PCX disburses the fiat to the registered beneficiary automatically.
The deposit must match crypto_amount, crypto_currency, and crypto_network exactly. Sending the wrong asset, wrong network, or a different amount can delay or fail the payout.
next_action values
| Value | When | What to do |
|---|
make_transfer | On-ramp bank transfer | Display payment_instructions (account name, number, bank, reference) so the customer can send funds. The reference is mandatory — payments without it may not reconcile. |
approve_prompt | On-ramp mobile money | The provider has pushed a prompt (e.g. M-PESA STK push) to the customer’s phone. Show payment_instructions.message and prompt them to approve. |
send_crypto | Off-ramp | Display payment_instructions (wallet address, crypto amount, currency, network, expiry) so the customer can send crypto to the PCX-provided wallet. Fiat payout to the beneficiary starts once the deposit confirms. |
wait | Any flow with no client action (e.g. off-ramp when the provider did not return settlement details) | PCX continues processing in the background. Wait for the webhook or poll for status. |
null | Provider data missing | The initiation succeeded but instructions could not be built (for example, an on-ramp bank transfer where bankInfo was missing). Fall back to polling and surface a generic “processing” state. |
The crypto_amount reflects what the customer will receive (or send, for off-ramp) at the locked spread-adjusted rate. The expires_at field indicates when the collection window closes — prompt your user to complete payment before then.
Rate validation and the RATE_STALE error
PCX validates the client_rate you submit against the current live rate. If it has drifted more than 0.5%, the request is rejected:
{
"statusCode": 422,
"error": "RATE_STALE",
"message": "Rate has drifted. Please re-fetch the exchange rate."
}
When you receive RATE_STALE, re-fetch the rate from GET /organizations/admin/exchange-rate, present the updated rate to the user, and re-submit with the new client_rate and org_rate_id.
Always fetch a fresh rate immediately before presenting it to the user and before submitting the ramp request. Do not cache rates across sessions or reuse org_rate_id values.
Step 4 — Track ramp status
Poll for the final outcome using the payment_id returned in Step 3:
GET /payments/{payment_id}
Or look up by transaction ID:
GET /payments/transaction/{transaction_id}
Terminal statuses:
| Status | Meaning |
|---|
completed | Ramp successful — crypto settled (on-ramp) or fiat paid out (off-ramp) |
failed | Ramp failed — check failure_reason |
canceled | Canceled before processing |
Step 5 — Receive webhook events
Register a webhook endpoint to receive real-time status updates instead of polling:
POST /public/webhooks
{
"endpoint_url": "https://your-app.com/webhooks/pcx"
}
PCX delivers a POST to your endpoint when the ramp status changes. Respond with 2xx to confirm receipt. Failed deliveries are retried with exponential backoff. See Webhooks for the full event schema.
Field reference
Common fields (both directions)
| Field | Required | Description |
|---|
direction | Yes | on_ramp (fiat → crypto) or off_ramp (crypto → fiat) |
user_id | Yes | ID of the initiating user |
org_id | Yes | Your organisation ID |
fiat_currency | Yes | ISO 4217 fiat currency code (e.g. NGN, KES) |
fiat_amount | Yes | Amount in fiat currency — what the customer pays (on-ramp) or receives (off-ramp) |
client_rate | Yes | The rate you fetched and displayed to the user. Must be within 0.5% of the current live rate. |
crypto_currency | Yes | Crypto asset code (e.g. USDC, USDT, EURC, BTC) |
crypto_network | Yes | Blockchain network (e.g. ethereum, polygon, tron) |
payment_method | Yes | mobile_money or bank_transfer |
country | Yes | ISO alpha-2 country code of the fiat side |
payer_details | Yes | Object with name, email, and optionally phone of the paying party |
customer_type | No | retail (default) or institution |
business_id | Institution only | Required when customer_type is institution |
business_name | Institution only | Required when customer_type is institution |
metadata | No | Arbitrary key-value pairs included in webhook payloads |
Retail customers (customer_type = "retail") cannot currently be processed on some corridors due to upstream KYC data processing constraints. For affected corridors, submit the request with customer_type = "institution" and include business_id and business_name.
On-ramp fields
| Field | Required | Description |
|---|
wallet_address | Yes | Destination crypto wallet address. PCX settles crypto here directly. |
mobile_money_details | Mobile money only | Object with provider and phone_number |
bank_details | Bank transfer only | Payer’s source bank account details |
Off-ramp fields
| Field | Required | Description |
|---|
beneficiary_id | Yes | ID of the registered beneficiary receiving the fiat payout |
beneficiary_name | Yes | Full name of the beneficiary |
destination | Yes | Destination routing details — bank account number, bank code, and bank name |