ℹ️ Note: Paycrest currently supports stablecoin-to-fiat (offramp) transactions only. Fiat-to-stablecoin (onramp) is coming in Q3 2025.
In this guide, we demonstrate how to enable off-ramps for users with the Sender API. The main difference between the Sender API and the Gateway contract is that users get a receiving address to pay for rather than connecting their non-custodial wallets. This means users can off-ramp directly from any wallet.

Getting Started

Step 1: Obtain API Credentials

First, you need to get the API Key from your sender dashboard. Visit your Sender Dashboard to retrieve your API Key and API Secret. If you’re a new user, sign up as a “sender” and complete our Know-Your-Business (KYB) process. Your API Secret should always be kept secret - we’ll get to this later in the article.

Step 2: Configure Tokens

Head over to the settings page of your Sender Dashboard to configure the feePercent, feeAddress, and refundAddress across the tokens and blockchain networks you intend to use.

Step 3: Authentication Setup

Include your API Key in the “API-Key” header of every request you make to Paycrest Offramp API.
const headers = {
  "API-Key": "208a4aef-1320-4222-82b4-e3bca8781b4b",
};
This is because requests without a valid API key will fail with status code 401: Unauthorized.

Creating Payment Orders

Step 1: Fetch the Rate

Before creating a payment order, you must first fetch the current rate for your token/amount/currency combination. This ensures you’re using an up-to-date rate that’s achievable by the system.
# Get rate for USDT on Base network
curl -X GET "https://api.paycrest.io/v1/rates/USDT/100/NGN?network=base" \
  -H "Content-Type: application/json"
The rate endpoint is publicly accessible and doesn’t require authentication. However, the rate you receive is only valid for a limited time and should be used immediately when creating your payment order.
Always fetch a fresh rate before creating each payment order. Rates can fluctuate frequently, and using an outdated rate will cause your order creation to fail.

Step 2: Create the Payment Order

Now that you have the current rate, you can create the payment order using that rate.
# First, fetch the current rate
RATE=$(curl -s -X GET "https://api.paycrest.io/v1/rates/USDT/100/NGN?network=base" \
  -H "Content-Type: application/json" | jq -r '.data')

# Then create the order with the fetched rate
curl -X POST "https://api.paycrest.io/v1/sender/orders" \
  -H "API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100,
    "token": "USDT",
    "network": "base",
    "rate": "'$RATE'",
    "recipient": {
      "institution": "GTB",
      "accountIdentifier": "1234567890",
      "accountName": "John Doe",
      "currency": "NGN",
      "memo": "Salary payment for January 2024"
    },
    "reference": "payment-123",
    "returnAddress": "0x1234567890123456789012345678901234567890"
  }'

Handle the Response

// The response includes important information
const {
  id,              // Order ID for tracking
  receiveAddress,  // Address to send tokens to
  validUntil,      // Expiration time
  senderFee,       // Fee amount
  transactionFee   // Network transaction fee
} = order;

// Store the order ID for tracking
await saveOrderToDatabase(order.id, order);

Send Tokens to Receive Address

// Using viem to send tokens
import { createPublicClient, createWalletClient, http, getContract, parseUnits } from 'viem';
import { base } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';

const publicClient = createPublicClient({
  chain: base,
  transport: http('https://mainnet.base.org')
});

const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const walletClient = createWalletClient({
  account,
  chain: base,
  transport: http('https://mainnet.base.org')
});

// USDT contract on Base
const usdtContract = getContract({
  address: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', // USDT on Base
  abi: [{
    name: 'transfer',
    type: 'function',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' }
    ],
    outputs: [{ name: '', type: 'bool' }],
    stateMutability: 'nonpayable'
  }],
  publicClient,
  walletClient
});

// Send tokens to the receive address
const { request } = await usdtContract.simulate.transfer({
  args: [order.receiveAddress, parseUnits(order.amount, 6)] // USDT has 6 decimals
});

const hash = await walletClient.writeContract(request);
console.log('Transaction hash:', hash);
The amount you send to the receive address should be the sum of amount, senderFee, and transactionFee as returned in the order response.

Order Status Monitoring

Your status can either be any of the following:
  • payment_order.pending - Order created, waiting for provider assignment
  • payment_order.validated - Funds have been sent to recipient’s bank/mobile network (value transfer confirmed)
  • payment_order.expired - Order expired without completion
  • payment_order.settled - Order fully completed on blockchain
  • payment_order.refunded - Funds refunded to sender
Once you deploy your server and get the endpoint, you can listen to payment order events by configuring the Webhook URL in your dashboard settings. We trigger various events based on the status of the payment order. Our webhook events are sent exponentially until 24 hours from when the first one is sent. If pending, your frontend would have to continue polling till it gets back a conclusive response - either validated, expired, or refunded.
You can tell your user the transaction was successful (or provide value) at the validated status, since this indicates funds have been sent to the recipient’s bank/mobile network. The settled status occurs when the provider has received the stablecoin on-chain, which is separate from the sender-to-recipient money flow.

Webhook Implementation

// Server setup and webhook endpoint
app.post("/webhook", async (req, res, next) => {
  const signature = req.get("X-Paycrest-Signature");
  if (!signature) return false;

  if (!verifyPaycrestSignature(req.body, signature, process.env.API_SECRET!)) {
    return res.status(401).send("Invalid signature");
  }
  console.log("Webhook received:", req.body);
  try {
    const transaction = await prisma.transaction.create({
      data: {
        id: req.body.data.id,
        status: req.body.event,
      },
    });
    res.json({ data: transaction });
  } catch (err) {
    next(err);
  }
  res.status(200).send("Webhook received");
});

function verifyPaycrestSignature(requestBody, signatureHeader, secretKey) {
  const calculatedSignature = calculateHmacSignature(requestBody, secretKey);
  return signatureHeader === calculatedSignature;
}

function calculateHmacSignature(data, secretKey) {
  const crypto = require('crypto');
  const key = Buffer.from(secretKey);
  const hash = crypto.createHmac("sha256", key);
  hash.update(data);
  return hash.digest("hex");
}
Webhook URLs are configured through the Sender Dashboard settings, not via API. Visit your dashboard to set up your webhook endpoint URL.

Polling Implementation

// Status polling endpoint
app.get("/transactions/:id", async (req, res, next) => {
  const { id } = req.params;
  const transaction = await prisma.transaction.findUnique({
    where: { id },
  });
  res.json({ data: transaction ? transaction : 'Non-existent transaction' });
});

// Poll for status updates
async function checkOrderStatus(orderId) {
  try {
    const response = await fetch(`https://api.paycrest.io/v1/sender/orders/${orderId}`, {
      headers: { "API-Key": "YOUR_API_KEY" }
    });
    const order = await response.json();
    switch (order.status) {
      case 'pending':
        console.log('Order is pending provider assignment');
        break;
      case 'validated':
        console.log('Funds have been sent to recipient\'s bank/mobile network (value transfer confirmed)');
        await handleOrderValidated(order);
        break;
      case 'settled':
        console.log('Order has been settled on blockchain');
        await handleOrderSettled(order);
        break;
      case 'refunded':
        console.log('Order was refunded to the sender');
        await handleOrderRefunded(order);
        break;
      case 'expired':
        console.log('Order expired without completion');
        await handleOrderExpired(order);
        break;
    }
    return order;
  } catch (error) {
    console.error('Error checking order status:', error);
    throw error;
  }
}

Error Handling

Rate Fetching Errors

When fetching rates, you may encounter these common errors:
  • 400 Bad Request: Invalid token, amount, or currency combination
  • 404 Not Found: No provider available for the specified parameters
  • Network Errors: Connection issues or timeouts
async function fetchRateWithErrorHandling(token, amount, currency, network) {
  try {
    const response = await fetch(
      `https://api.paycrest.io/v1/rates/${token}/${amount}/${currency}?network=${network}`,
      {
        method: "GET",
        headers: {
          "Content-Type": "application/json"
        }
      }
    );
    
    if (!response.ok) {
      if (response.status === 400) {
        const errorData = await response.json();
        throw new Error(`Rate validation failed: ${errorData.message}`);
      } else if (response.status === 404) {
        throw new Error('No provider available for this token/amount/currency combination');
      } else {
        throw new Error(`Rate fetch failed: ${response.statusText}`);
      }
    }
    
    const rateData = await response.json();
    return rateData.data;
  } catch (error) {
    console.error('Error fetching rate:', error);
    throw error;
  }
}

Order Creation Error Handling

When creating payment orders, you may encounter these common errors:
  • 400 Bad Request: Invalid payload, missing required fields, or validation errors
  • 401 Unauthorized: Invalid or missing API key
  • 429 Too Many Requests: Rate limit exceeded
  • 500 Internal Server Error: Server-side issues
{
  "status": "error",
  "message": "Failed to validate payload",
  "data": {
    "field": "Rate",
    "message": "Provided rate 1500.00 is not achievable. Available rate is 1500.50"
  }
}

API Error Handling

async function createPaymentOrder(orderData) {
  try {
    const response = await fetch("https://api.paycrest.io/v1/sender/orders", {
      method: "POST",
      headers: {
        "API-Key": "YOUR_API_KEY",
        "Content-Type": "application/json"
      },
      body: JSON.stringify(orderData)
    });
    
    if (!response.ok) {
      if (response.status === 400) {
        // Validation error
        const validationErrors = await response.json();
        throw new Error(`Validation failed: ${JSON.stringify(validationErrors)}`);
      } else if (response.status === 401) {
        // Authentication error
        throw new Error('Invalid API key');
      } else if (response.status === 429) {
        // Rate limit exceeded
        throw new Error('Rate limit exceeded. Please try again later.');
      } else {
        // Other errors
        throw new Error(`API error: ${response.statusText}`);
      }
    }
    
    return await response.json();
  } catch (error) {
    console.error('Error creating payment order:', error);
    throw error;
  }
}

Retry Logic

async function createOrderWithRetry(orderData, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await createPaymentOrder(orderData);
    } catch (error) {
      if (attempt === maxRetries) {
        throw error;
      }
      
      // Wait before retrying (exponential backoff)
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

Production Considerations

// Use environment variables for sensitive data
const config = {
  apiKey: process.env.PAYCREST_API_KEY,
  webhookSecret: process.env.PAYCREST_WEBHOOK_SECRET
};

// Validate webhook signatures
app.post('/webhooks/paycrest', async (req, res) => {
  const signature = req.headers['x-paycrest-signature'];
  
  if (!validateWebhookSignature(req.body, signature, config.webhookSecret)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  // Process webhook
  // ...
});

Testing

// Using Jest for testing
describe('Paycrest Sender API Integration', () => {
  test('should create payment order successfully', async () => {
    const orderData = {
      amount: 100,
      token: 'USDT',
      network: 'base',
      rate: 1500.50,
      recipient: {
        institution: 'GTB',
        accountIdentifier: '1234567890',
        accountName: 'Test User',
        currency: 'NGN'
      }
    };
    
    const order = await createPaymentOrder(orderData);
    
    expect(order.id).toBeDefined();
    expect(order.receiveAddress).toBeDefined();
    expect(order.status).toBe('pending');
  });
  
  test('should handle API errors gracefully', async () => {
    const invalidOrderData = {
      amount: -100, // Invalid amount
      token: 'USDT',
      network: 'base',
      rate: 1500.50
    };
    
    await expect(createPaymentOrder(invalidOrderData))
      .rejects
      .toThrow('Validation failed');
  });
});

Deployment Checklist

Before going live, ensure you have:
  • KYC verification completed
  • API credentials secured
  • Webhook endpoints configured and tested
  • Error handling implemented
  • Monitoring and logging set up
  • Rate limiting configured
  • Security measures implemented
  • Testing completed with small amounts
  • Documentation updated
This backend structure can be done in any custom way depending on your app as long as the webhook validates and stores the correct payload sent to it.
Choose this method if you want a simple, offchain integration for your platform or business.