Custom Integration Guide

This guide shows you how to build your own payment experience instead of using our hosted payment page. Display QR codes, Monero addresses, and payment status directly in your UI — full control, your brand.

When to use this: You want to embed Monero payments directly in your checkout flow without redirecting customers to your-server.com/pay/:id.

Overview

The flow is simple:

  1. Your backend creates a charge via our API
  2. Your frontend displays the XMR address, amount, and QR code
  3. Your frontend polls for payment status (or you wait for a webhook)
  4. Your backend fulfills the order when confirmed

Important: Your API key must never appear in client-side code. Always create charges from your backend server.

Step 1: Create a Charge (Backend)

When a customer clicks "Pay with Monero," your backend creates a charge and returns the payment details to your frontend.

Node.js (Express)

const REBELPAY_API = 'https://your-server.com';
const API_KEY = process.env.REBELPAY_API_KEY; // rp_live_...

app.post('/checkout/monero', async (req, res) => {
  const { orderId, amount } = req.body;

  // Create charge on Rebel Pay
  const response = await fetch(`${REBELPAY_API}/api/charges`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': API_KEY
    },
    body: JSON.stringify({
      amount: amount,
      currency: 'USD',
      metadata: {
        order_id: orderId,
        customer_id: req.user.id
      }
    })
  });

  const charge = await response.json();

  // Store charge ID with your order
  await db.orders.update(orderId, {
    rebelpay_charge_id: charge.id
  });

  // Return payment details to frontend
  // (Never send your API key to the client)
  res.json({
    chargeId: charge.id,
    address: charge.subaddress,
    amountXmr: charge.amount_xmr,
    amountUsd: charge.amount,
    expiresAt: charge.expires_at
  });
});

Python (Flask)

import requests, os

REBELPAY_API = 'https://your-server.com'
API_KEY = os.environ['REBELPAY_API_KEY']

@app.route('/checkout/monero', methods=['POST'])
def create_monero_checkout():
    data = request.get_json()

    charge = requests.post(f'{REBELPAY_API}/api/charges',
        headers={
            'Content-Type': 'application/json',
            'x-api-key': API_KEY
        },
        json={
            'amount': data['amount'],
            'currency': 'USD',
            'metadata': {
                'order_id': data['order_id'],
                'customer_id': current_user.id
            }
        }
    ).json()

    # Store charge ID with your order
    db.orders.update(data['order_id'],
        rebelpay_charge_id=charge['id'])

    return jsonify({
        'chargeId': charge['id'],
        'address': charge['subaddress'],
        'amountXmr': charge['amount_xmr'],
        'amountUsd': charge['amount'],
        'expiresAt': charge['expires_at']
    })

PHP

<?php
$apiKey = getenv('REBELPAY_API_KEY');
$data = json_decode(file_get_contents('php://input'), true);

$ch = curl_init('https://your-server.com/api/charges');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        "x-api-key: $apiKey"
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => $data['amount'],
        'currency' => 'USD',
        'metadata' => [
            'order_id' => $data['order_id']
        ]
    ])
]);

$charge = json_decode(curl_exec($ch), true);
curl_close($ch);

echo json_encode([
    'chargeId' => $charge['id'],
    'address' => $charge['subaddress'],
    'amountXmr' => $charge['amount_xmr'],
    'amountUsd' => $charge['amount'],
    'expiresAt' => $charge['expires_at']
]);

Step 2: Display Payment Details (Frontend)

Once your backend returns the charge details, display them to the customer. Here's what to show:

  • XMR amount — the exact amount to send
  • Monero address — with a copy button
  • QR code — scannable by any Monero wallet
  • Expiry countdown — charges expire after 60 minutes

QR Codes

You have two options for QR codes:

Option A: Use our QR endpoint (server-side)

Fetch the QR code from your server using your API key, then serve the image to your customer. This endpoint requires authentication and cannot be used directly in <img> tags.

curl https://your-server.com/api/charges/CHARGE_ID/qr?format=png \
  -H "x-api-key: your-api-key-here" \
  --output qr.png

The QR code encodes a monero: URI (see below) so wallets auto-fill the address and amount.

Tip: For the simplest integration, redirect customers to /pay/{charge_id} — our hosted payment page includes QR codes, address display, and auto-refresh with no setup needed.

Option B: Generate QR codes yourself

Use any QR library to encode a monero: URI. This avoids an extra request to our servers.

// JavaScript — using 'qrcode' npm package
const QRCode = require('qrcode');

const uri = `monero:${address}?tx_amount=${amountXmr}`;
const qrDataUrl = await QRCode.toDataURL(uri, {
  width: 250,
  margin: 2,
  color: { dark: '#000000', light: '#ffffff' }
});
# Python — using 'qrcode' package
import qrcode, io, base64

uri = f"monero:{address}?tx_amount={amount_xmr}"
img = qrcode.make(uri)
buffer = io.BytesIO()
img.save(buffer, format='PNG')
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
// PHP — using 'chillerlan/php-qrcode'
use chillerlan\QRCode\QRCode;

$uri = "monero:{$address}?tx_amount={$amountXmr}";
$qr = (new QRCode)->render($uri);

The monero: URI Format

Monero wallets (Cake Wallet, Monerujo, Feather, CLI) recognize the monero: URI scheme. When a user scans the QR, their wallet auto-fills the address and amount.

monero:<address>?tx_amount=<amount>

Parameters:

  • tx_amount — XMR amount (max 12 decimal places — piconero precision)
  • recipient_name — (optional) merchant name shown in wallet
  • tx_description — (optional) payment description

Example:

monero:84Hv16y6x7BTie3ib5Sx...?tx_amount=0.071428571428&recipient_name=Acme+Store
Precision matters: Monero supports a maximum of 12 decimal places (1 piconero = 0.000000000001 XMR). Our API returns amounts already rounded to 12 decimals. Do not add extra precision in your URI.

Example Payment UI (HTML)

<div class="payment-box">
  <h2>Pay with Monero</h2>

  <!-- QR Code (served from your backend, fetched with API key) -->
  <img id="qr-code"
    src="/your-backend/qr-proxy/CHARGE_ID"
    alt="Monero QR Code" width="250" height="250" />

  <!-- Amount -->
  <div class="amount">
    <span class="xmr-amount">0.071428 XMR</span>
    <span class="usd-amount">($25.00 USD)</span>
  </div>

  <!-- Address with copy -->
  <div class="address">
    <code id="xmr-address">84Hv16y6x7BTie...</code>
    <button onclick="navigator.clipboard.writeText(
      document.getElementById('xmr-address').textContent
    )">Copy</button>
  </div>

  <!-- Status -->
  <div id="status">Waiting for payment...</div>

  <!-- Expiry -->
  <div id="countdown"></div>
</div>

Step 3: Poll for Payment Status

While the customer's payment page is open, poll the charge status to update the UI in real time. We recommend polling every 10-15 seconds.

Status Flow

pending → (payment detected) → confirming → (10 confirmations) → confirmed
    ↓
 (60 min) → expired

JavaScript Polling

// Poll your own backend (not our API directly — protect your key)
async function pollPaymentStatus(chargeId) {
  const poll = setInterval(async () => {
    const res = await fetch(`/api/order-status/${chargeId}`);
    const data = await res.json();

    switch (data.status) {
      case 'pending':
        updateUI('Waiting for payment...');
        break;

      case 'confirming':
        updateUI(`Payment detected! ${data.confirmations}/10 confirmations...`);
        break;

      case 'confirmed':
        updateUI('Payment confirmed! ✓');
        clearInterval(poll);
        // Redirect to success page
        window.location.href = `/order/success/${chargeId}`;
        break;

      case 'expired':
        updateUI('Payment expired. Please try again.');
        clearInterval(poll);
        break;
    }
  }, 15000); // every 15 seconds
}

Your backend status endpoint should call GET /api/charges/:id with your API key and return the status:

// Backend: proxy status without exposing API key
app.get('/api/order-status/:chargeId', async (req, res) => {
  const response = await fetch(
    `${REBELPAY_API}/api/charges/${req.params.chargeId}`,
    { headers: { 'x-api-key': API_KEY } }
  );
  const charge = await response.json();
  res.json({
    status: charge.status,
    confirmations: charge.confirmations || 0
  });
});

No-JavaScript Alternative

If your site doesn't use JavaScript (e.g., Tor "Safest" mode), use a <meta> refresh to auto-reload:

<!-- Refresh every 30 seconds -->
<meta http-equiv="refresh" content="30">

Render the status server-side on each page load. This is exactly how our hosted payment page works in Tor-compatible mode.

Step 4: Handle Confirmation (Backend)

Don't rely on polling alone. Use webhooks as the authoritative confirmation. Polling is for UX — webhooks are for fulfillment.

// Webhook handler — this is where you fulfill orders
app.post('/webhooks/rebelpay',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // 1. Verify signature (see Webhooks docs)
    const signature = req.headers['x-rebelpay-signature'];
    if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body);

    // 2. Handle events
    switch (event.event) {
      case 'charge.confirmed':
        const orderId = event.data.metadata.order_id;

        // Idempotency: check if already fulfilled
        const order = db.orders.get(orderId);
        if (order.status === 'fulfilled') break;

        // Fulfill: activate subscription, send product, etc.
        db.orders.update(orderId, { status: 'fulfilled' });
        activateCustomer(event.data.metadata.customer_id);
        break;

      case 'charge.expired':
        db.orders.update(event.data.metadata.order_id,
          { status: 'expired' });
        break;

      case 'charge.underpaid':
        // Flag for manual review
        db.orders.update(event.data.metadata.order_id,
          { status: 'underpaid', needs_review: true });
        break;
    }

    res.status(200).send('OK');
  }
);

Expiry Countdown

Charges expire after 60 minutes. Show a countdown so customers know how long they have:

function startCountdown(expiresAt) {
  const expiry = new Date(expiresAt).getTime();
  const el = document.getElementById('countdown');

  const timer = setInterval(() => {
    const remaining = expiry - Date.now();

    if (remaining <= 0) {
      el.textContent = 'Expired';
      clearInterval(timer);
      return;
    }

    const minutes = Math.floor(remaining / 60000);
    const seconds = Math.floor((remaining % 60000) / 1000);
    el.textContent = `${minutes}:${seconds.toString().padStart(2, '0')} remaining`;
  }, 1000);
}

Best Practices

Security

  • Never expose your API key — all charge creation and status checks go through your backend
  • Always verify webhook signatures — don't fulfill orders based on unverified webhooks
  • Double-check amounts — verify the charge amount matches your order before fulfilling
  • Use idempotency — webhooks may fire more than once; use the charge ID to prevent double-fulfillment
  • Validate metadata — match metadata against your own records; don't blindly trust it

User Experience

  • Show both XMR and fiat amounts — customers want to confirm the dollar value
  • Make the address copyable — one-click copy is essential for desktop users
  • QR code size — at least 200×200px for reliable scanning
  • Show confirmations progress — "3/10 confirmations" tells the user it's working
  • Handle expiry gracefully — offer a "Try again" button that creates a fresh charge (price refreshes automatically)
  • Test with real wallets — Cake Wallet (mobile), Feather (desktop), and the CLI wallet all handle monero: URIs

Amount Formatting

  • Display: show 4-6 decimal places for readability (e.g., 0.0714 XMR)
  • QR/URI: use the full precision from the API response (up to 12 decimal places)
  • Never round the amount in the QR code — the customer must send the exact amount for automatic matching

Complete Example: Node.js + Express

A minimal but production-ready integration:

// server.js
const express = require('express');
const crypto = require('crypto');
const app = express();

const REBELPAY_API = 'https://your-server.com';
const API_KEY = process.env.REBELPAY_API_KEY;
const WEBHOOK_SECRET = process.env.REBELPAY_WEBHOOK_SECRET;

// Create charge
app.post('/api/pay', express.json(), async (req, res) => {
  const charge = await fetch(`${REBELPAY_API}/api/charges`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': API_KEY
    },
    body: JSON.stringify({
      amount: req.body.amount,
      currency: 'USD',
      metadata: { order_id: req.body.orderId }
    })
  }).then(r => r.json());

  res.json({
    chargeId: charge.id,
    address: charge.subaddress,
    amountXmr: charge.amount_xmr,
    amountUsd: charge.amount,
    expiresAt: charge.expires_at,
    // QR code: fetch server-side with API key, or generate client-side with a QR library
    // qrUrl: proxy through your backend (requires x-api-key header)
    qrUrl: `/your-backend/qr-proxy/${charge.id}`
  });
});

// Poll status (frontend calls this)
app.get('/api/pay/:id/status', async (req, res) => {
  const charge = await fetch(
    `${REBELPAY_API}/api/charges/${req.params.id}`,
    { headers: { 'x-api-key': API_KEY } }
  ).then(r => r.json());

  res.json({
    status: charge.status,
    confirmations: charge.confirmations || 0
  });
});

// Webhook (Rebel Pay calls this)
app.post('/webhooks/rebelpay',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-rebelpay-signature'];
    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(req.body).digest('hex');

    if (!crypto.timingSafeEqual(
      Buffer.from(sig, 'hex'),
      Buffer.from(expected, 'hex')
    )) {
      return res.status(401).send('Bad signature');
    }

    const { event, data } = JSON.parse(req.body);

    if (event === 'charge.confirmed') {
      // YOUR LOGIC: activate account, ship product, etc.
      fulfillOrder(data.metadata.order_id);
    }

    res.sendStatus(200);
  }
);

app.listen(3000);

Next Steps

  • Webhooks — full event reference and signature verification in Python, PHP, Node.js
  • API Reference — all endpoints, parameters, and responses
  • FAQ — common questions about confirmations, expiry, and underpayments