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
| File | Purpose |
|---|---|
app/lib/plisio.server.ts | PlisioClient class — invoice creation, status polling, HMAC verification |
app/routes/api.plisio-invoice.ts | POST /api/plisio-invoice — creates new invoices |
app/routes/api.plisio-webhook.ts | GET/POST /api/plisio-webhook — receives payment notifications |
app/routes/api.plisio-status.ts | GET /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:
import { createPlisioClient } from '@lib/plisio.server';
const client = createPlisioClient({
PLISIO_API_KEY: env.PLISIO_API_KEY,
PLISIO_SECRET_KEY: env.PLISIO_SECRET_KEY,
});Methods
| Method | Description |
|---|---|
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
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
{
"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:
| Check | Limit | Response |
|---|---|---|
| Pending transactions | Max 5 per user email | 429 — wait for existing to complete |
| Recent transactions | Max 3 per minute per user email | 429 — 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:
NaNandInfinityvalues- Scientific notation (
1e5) - Floating-point precision exploits
- Overflow attacks
- Negative values
Failed validations are logged to security_events via logAmountValidationFailed().
Order Number Generation
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 SupabaseHMAC-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:
- Remove
verify_hashfrom payload - Sort remaining keys alphabetically (
ksortequivalent) - Handle special fields (
expire_utc→ string,tx_urls→ decode HTML entities) - Serialize using PHP format (
php-serializelibrary) - Compute HMAC-SHA1 using Web Crypto API
- Compare with XOR-based constant-time comparison
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.
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:
- Calls Plisio API
GET /api/v1/operations/{txnId} - Maps Plisio status to internal status
- Looks up the existing
payment_transactionsrow byprovider_txn_idusinggetTransactionByProviderTxnId() - Updates the row by its UUID via
updateTransactionStatus()
Status Mapping
| Plisio Status | Internal Status | PaymentStatus (DB) |
|---|---|---|
completed | confirmed | completed |
new, pending | pending | pending |
expired, cancelled, error | failed | error |
Environment Variables
| Variable | Required | Description |
|---|---|---|
PLISIO_API_KEY | Yes | API key from Plisio dashboard |
PLISIO_SECRET_KEY | Yes | Secret key for HMAC verification |
PLISIO_WEBHOOK_IPS | No | Comma-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:
| Step | Operation | Fields Set |
|---|---|---|
| Invoice creation | createPaymentTransaction() | user_email, order_number, amount, amount_usd, provider='plisio', status='pending' |
| After Plisio API | updateTransactionStatus() | provider_txn_id, provider_invoice_url |
| Webhook received | updateTransactionStatus() | status, tx_hash, crypto_currency, wallet_address, conversion_rate, completed_at |
| Status polling | updateTransactionStatus() | 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
| Step | Where | What Happens |
|---|---|---|
| 1 | Client | User selects amount, clicks pay |
| 2 | api.plisio-invoice | Validate, CREATE pending row in payment_transactions, call Plisio API, UPDATE row with provider_txn_id |
| 3 | Plisio | User pays on Plisio-hosted page |
| 4 | api.plisio-webhook | Plisio notifies us (6-layer security) |
| 5 | webhook-processor | UPDATE existing payment_transactions row (status, tx_hash, crypto details); event auto-logged |
| 6 | api.plisio-status | Client polls; looks up by provider_txn_id, updates status by UUID |