Skip to content

Plisio Cryptocurrency Payments

UberLotto uses Plisio as its cryptocurrency payment gateway. Plisio provides a white-label invoicing API that lets users pay with Bitcoin, Ethereum, Litecoin, and other cryptocurrencies while the platform receives USD-denominated invoices.

Key Files

FilePurpose
app/lib/plisio.server.tsPlisioClient class — invoice creation, status polling, HMAC verification
app/routes/api.plisio-invoice.tsPOST /api/plisio-invoice — creates new invoices
app/routes/api.plisio-webhook.tsGET/POST /api/plisio-webhook — receives payment notifications
app/routes/api.plisio-status.tsGET /api/plisio-status?txn_id=xxx — polls transaction status

Payment Flow

All transactions are stored in the unified payment_transactions table with provider = 'plisio'. The payment_transaction_events table automatically logs every status change via a database trigger.

User clicks "Pay with Crypto"


┌──────────────────────┐
│  POST /api/plisio-   │
│  invoice             │
│  { amount, email }   │
└──────────┬───────────┘


┌──────────────────────┐
│  Rate-limit checks   │  ← Max 5 pending / Max 3 per minute
│  (per user email)    │
└──────────┬───────────┘


┌──────────────────────┐
│  Amount validation   │  ← Security-hardened: NaN, Infinity,
│  (sanitize input)    │    precision exploits blocked
└──────────┬───────────┘


┌──────────────────────┐
│  CREATE pending row  │  ← payment_transactions: stores email,
│  in DB (status=      │    order_number, amount, provider='plisio'
│  pending)            │    BEFORE calling Plisio API
└──────────┬───────────┘


┌──────────────────────┐
│  Plisio API call     │  ← GET /api/v1/invoices/new
│  (create invoice)    │    with api_key, source_amount, etc.
└──────────┬───────────┘


┌──────────────────────┐
│  UPDATE row with     │  ← Sets provider_txn_id + invoice_url
│  Plisio response     │    on the existing pending row
└──────────┬───────────┘


┌──────────────────────┐
│  Return invoice_url  │  ← User redirected to Plisio
│  + txn_id + QR code  │    payment page
└──────────────────────┘

           │  (async)

┌──────────────────────┐
│  Plisio sends        │  ← POST /api/plisio-webhook
│  webhook callbacks   │    with payment status updates
└──────────────────────┘


┌──────────────────────┐
│  6-layer security    │  ← See "Webhook Security Pipeline"
│  validation          │
└──────────┬───────────┘


┌──────────────────────┐
│  UPDATE existing     │  ← Looks up row by order_number,
│  transaction row     │    updates status, tx_hash, crypto
│                      │    details; auto-logs event
└──────────────────────┘

PlisioClient Class

The PlisioClient in app/lib/plisio.server.ts wraps the Plisio REST API:

typescript
import { createPlisioClient } from '@lib/plisio.server';

const client = createPlisioClient({
  PLISIO_API_KEY: env.PLISIO_API_KEY,
  PLISIO_SECRET_KEY: env.PLISIO_SECRET_KEY,
});

Methods

MethodDescription
createInvoice(params)Creates a new payment invoice via GET /api/v1/invoices/new
checkStatus(txnId)Polls transaction status via GET /api/v1/operations/{txnId}
verifyWebhookSignature(payload, hash)HMAC-SHA1 verification using PHP serialize format

API Format

Plisio uses GET requests with query parameters for all API calls, not POST with JSON bodies. The PlisioClient constructs URL query strings accordingly.

Invoice Request Parameters

typescript
interface PlisioInvoiceRequest {
  source_amount: number;      // Amount in USD
  source_currency: string;    // Always "USD"
  order_number: string;       // Generated: "UL-{timestamp}-{random}"
  order_name: string;         // "UberLotto Credit Purchase - {name} - {order}"
  callback_url: string;       // Webhook URL for payment notifications
  email?: string;             // Customer email (optional)
}

Invoice Creation Endpoint

POST /api/plisio-invoice

Request Body

json
{
  "amount": 50,
  "email": "[email protected]",
  "customer_name": "John",
  "customer_lastname": "Doe",
  "customer_id": "gid://shopify/Customer/123"
}

Invoice Rate Limits

Before creating an invoice, the endpoint enforces per-user limits:

CheckLimitResponse
Pending transactionsMax 5 per user email429 — wait for existing to complete
Recent transactionsMax 3 per minute per user email429 — try again in a moment

These limits are enforced via Supabase queries (countPendingTransactions, countRecentTransactions) and only apply when an email is provided.

Amount Validation

Amounts pass through validateAmount() from app/lib/amount-validator.server.ts, which prevents:

  • NaN and Infinity values
  • Scientific notation (1e5)
  • Floating-point precision exploits
  • Overflow attacks
  • Negative values

Failed validations are logged to security_events via logAmountValidationFailed().

Order Number Generation

typescript
function generateOrderNumber(prefix = 'UL'): string {
  const timestamp = Date.now().toString();
  const random = Math.random().toString(36).substring(2, 8);
  return `${prefix}-${timestamp}-${random}`.toUpperCase();
}
// Example: "UL-1699892345678-A3B4C5"

Webhook Security Pipeline

When Plisio sends a payment notification to /api/plisio-webhook, the request passes through a 6-layer security pipeline (plus amount validation during processing):

Webhook Request (GET or POST)

    ┌────┴────┐
    │ Layer 1 │  IP Whitelist Check
    └────┬────┘  PLISIO_WEBHOOK_IPS env var

    ┌────┴────┐
    │ Layer 2 │  Rate Limiting
    └────┬────┘  IP (100/min) + Global (1000/min)

    ┌────┴────┐
    │ Layer 3 │  Data Extraction + HMAC-SHA1 Verification
    └────┬────┘  PHP serialize → sorted keys → HMAC compare

    ┌────┴────┐
    │ Layer 4 │  Replay Attack Prevention
    └────┬────┘  SHA-256 nonce in webhook_nonces table (5-min TTL)

    ┌────┴────┐
    │ Layer 5 │  Transaction Rate Limiting
    └────┬────┘  Per txn_id: 10 req/min

    ┌────┴────┐
    │ Layer 6 │  Duplicate Transaction Check
    └────┬────┘  Database-level deduplication

    ┌────┴────┐
    │ Process │  Amount & Currency Validation
    └─────────┘  Update existing transaction in Supabase

HMAC-SHA1 Verification

Correction from Manual Docs

The signature comparison uses a custom XOR-based constantTimeEqual() function, NOT crypto.timingSafeEqual. This is because the Web Crypto API (used in Cloudflare Workers / Oxygen) does not expose Node.js crypto.timingSafeEqual.

The HMAC verification follows Plisio's PHP-based algorithm:

  1. Remove verify_hash from payload
  2. Sort remaining keys alphabetically (ksort equivalent)
  3. Handle special fields (expire_utc → string, tx_urls → decode HTML entities)
  4. Serialize using PHP format (php-serialize library)
  5. Compute HMAC-SHA1 using Web Crypto API
  6. Compare with XOR-based constant-time comparison
typescript
private constantTimeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return result === 0;
}

Replay Protection Nonce Formula

Correction from Manual Docs

The nonce formula uses colon separators, NOT string concatenation with +. The fields are txn_id:status:amount:order_number — there is no timestamp component.

typescript
const nonceString = [
  webhookData.txn_id,
  webhookData.status,
  webhookData.amount,
  webhookData.order_number,
].join(':');

// SHA-256 hash via Web Crypto API
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(nonceString));

Status Polling Endpoint

GET /api/plisio-status?txn_id=xxx

The frontend polls this endpoint to check payment progress. It:

  1. Calls Plisio API GET /api/v1/operations/{txnId}
  2. Maps Plisio status to internal status
  3. Looks up the existing payment_transactions row by provider_txn_id using getTransactionByProviderTxnId()
  4. Updates the row by its UUID via updateTransactionStatus()

Status Mapping

Plisio StatusInternal StatusPaymentStatus (DB)
completedconfirmedcompleted
new, pendingpendingpending
expired, cancelled, errorfailederror

Environment Variables

VariableRequiredDescription
PLISIO_API_KEYYesAPI key from Plisio dashboard
PLISIO_SECRET_KEYYesSecret key for HMAC verification
PLISIO_WEBHOOK_IPSNoComma-separated whitelist IPs (e.g. "216.219.89.38")

Security

Both PLISIO_API_KEY and PLISIO_SECRET_KEY are server-only secrets. They must never be exposed to the client. All Plisio files use the .server.ts extension to enforce this.

Database Interaction

Both the invoice endpoint and webhook handler operate on the same payment_transactions row:

StepOperationFields Set
Invoice creationcreatePaymentTransaction()user_email, order_number, amount, amount_usd, provider='plisio', status='pending'
After Plisio APIupdateTransactionStatus()provider_txn_id, provider_invoice_url
Webhook receivedupdateTransactionStatus()status, tx_hash, crypto_currency, wallet_address, conversion_rate, completed_at
Status pollingupdateTransactionStatus()status, completed_at (via getTransactionByProviderTxnId() lookup)

The payment_transaction_events table automatically captures every status change via a database trigger, providing a complete audit trail.

Data Flow Summary

StepWhereWhat Happens
1ClientUser selects amount, clicks pay
2api.plisio-invoiceValidate, CREATE pending row in payment_transactions, call Plisio API, UPDATE row with provider_txn_id
3PlisioUser pays on Plisio-hosted page
4api.plisio-webhookPlisio notifies us (6-layer security)
5webhook-processorUPDATE existing payment_transactions row (status, tx_hash, crypto details); event auto-logged
6api.plisio-statusClient polls; looks up by provider_txn_id, updates status by UUID

UberLotto Technical Documentation