Accept crypto, settle in fiat
PurpleRail gives each order a quote and a unique deposit address, watches the blockchain, screens the funds, converts them and pays you in fiat. You integrate one API and one webhook.
- Create a payment from your server with the order amount in your currency.
- Redirect the customer to the
checkout_url. They pick a token and network, scan the QR or connect a wallet. - Receive webhooks as the payment is detected, confirmed, accepted and paid out. Fulfil the order on
accepted.
curl -X POST {{BASE}}/v1/payments \
-H "Authorization: Bearer $PURPLERAIL_KEY" \
-H "Content-Type: application/json" \
-d '{ "reference": "order-1042", "amount": "100.00", "currency": "EUR",
"return_url": "https://shop.example/thanks" }'
{
"id": "pay_7q2k9x4mf1d8c3a",
"status": "created",
"amount": { "currency": "EUR", "value": "100" },
"checkout_url": "{{BASE}}/checkout/pay_7q2k9x4mf1d8c3a",
...
}
Authentication
Send your API key as a bearer token. Keys start with pr_test_ in the sandbox and pr_live_ in production. Create and revoke them in Dashboard → Developers. Keys are shown once; keep them on your server, never in a browser or mobile app.
Keys are scoped: read-only keys can list and retrieve; full keys can also create payments and links. PSP keys can only see their merchants' payments and post decisions.
Payment lifecycle
| Status | Meaning | What you do |
|---|---|---|
accepted | Funds confirmed on-chain and cleared by compliance. | Fulfil the order. Your fiat is now owed to you. |
settled | Fiat paid out to your account (or included in a daily settlement). | Reconcile with your bank. |
underpaid | Less than the quoted total arrived. The customer can top up to the same address. | Wait, or contact the customer. |
under_review | Needs a person: late payment, wrong token, or elevated risk. | Nothing. You'll get accepted or rejected. |
rejected / refund_pending / refunded | Not accepted; funds are returned to the payer (or frozen if sanctioned). | Don't fulfil. |
expired | Nothing arrived in time. | Create a new payment if the customer comes back. |
Customers are never shown why a payment was reviewed or rejected, and neither are merchants: compliance details stay with the regulated parties.
Hosted checkout
The checkout handles token and network choice, quotes (locked for 15 minutes), the QR code, copy address, wallet connection, countdown, and every status screen including partial payments, late payments and wrong tokens. It's served with a strict Content-Security-Policy and renders the QR code server-side, so the address shown and the QR can't disagree.
Supported today: USDC and USDT on Ethereum, USDC on Polygon, USDT on Tron, ETH, BTC. Prices in 20 fiat currencies (see /v1/currencies).
Deposit wallets
For platforms and financial institutions that want an address rather than a checkout: POST /v1/deposit-wallets with a token and network returns a dedicated address. There's no expected amount: whatever arrives is screened, converted at the rate achieved, and settled. Status webhooks work the same way.
Payment links
No integration needed: create a link (fixed amount, or one the payer enters), share it or print its QR. Single-use links behave as invoices and can't be paid twice. POST /v1/payment-links, or create them in the dashboard.
Webhooks & signatures
Set your endpoint in Dashboard → Developers. We POST JSON events and retry with back-off until you answer 2xx (up to 10 attempts). Each event has a unique id: store it and ignore repeats.
POST /your/webhook
PurpleRail-Signature: t=1793886540,v1=5f2c…
PurpleRail-Event-Id: evt_…
{ "id": "evt_…", "type": "payment.status_changed", "created_at": "…",
"data": { "payment": { … }, "previous_status": "screening" } }
Verify every request: compute HMAC-SHA256 over "<t>.<raw body>" with your webhook secret, compare to v1 in constant time, and reject timestamps older than 5 minutes.
import crypto from 'node:crypto';
function verify(secret, header, rawBody) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(v1, 'hex');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f'{parts["t"]}.'.encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
function verify(string $secret, string $header, string $rawBody): bool {
parse_str(str_replace(',', '&', $header), $p);
if (abs(time() - (int)$p['t']) > 300) return false;
$expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $p['v1']);
}
Fees & settlement
Each merchant has a fee policy deciding who carries the conversion spread and the network fee: the customer (default), split (you absorb the spread), or you (the customer sends exactly the order value). The quote's fee_policy field shows which applied.
Payouts are either per payment or a daily batch per currency at 18:00 UTC, with a CSV report of every payment (gross, fee, net) from /v1/settlements/{id}/report.csv. Amounts are exact: everything is held as integer minor units internally and reconciled daily against custody and bank balances.
PSP decisions
If you are a PSP that owns the merchant relationship (decision model B), you receive payment.decision_required with the payment and the crypto-side evidence (source of funds, sanctions and risk signals). Answer with POST /v1/decisions/{id} before respond_by. If you don't, your configured timeout action applies (review or reject). PurpleRail always stops sanctioned or prohibited funds, whatever the decision.
Errors & idempotency
Errors return a JSON body {"error": {"code", "message"}} with 400 (invalid request), 401 (bad key), 403 (key lacks a scope), 404, 409 (conflict, e.g. a reference reused with a different amount) or 5xx.
reference makes payment creation idempotent: retry safely after a timeout and you get the same payment back.
Testing in the sandbox
The sandbox runs the full flow with simulated custody, liquidity, screening, off-ramp and payouts. Pay a test checkout with POST /sim/deposits (or the "Test-mode tools" on the checkout page). Use a from_address containing mix, bad or dead to see review, rejection and sanctions handling. A Sepolia testnet merchant accepts real testnet USDC and ETH from MetaMask.
API reference
Generated from the OpenAPI 3.1 spec. Import it into Postman or generate a client.