PurpleRailDevelopers

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.

  1. Create a payment from your server with the order amount in your currency.
  2. Redirect the customer to the checkout_url. They pick a token and network, scan the QR or connect a wallet.
  3. 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

created→awaiting_payment→payment_detected→confirming→screening→accepted→converting→settling→settled
StatusMeaningWhat you do
acceptedFunds confirmed on-chain and cleared by compliance.Fulfil the order. Your fiat is now owed to you.
settledFiat paid out to your account (or included in a daily settlement).Reconcile with your bank.
underpaidLess than the quoted total arrived. The customer can top up to the same address.Wait, or contact the customer.
under_reviewNeeds a person: late payment, wrong token, or elevated risk.Nothing. You'll get accepted or rejected.
rejected / refund_pending / refundedNot accepted; funds are returned to the payer (or frozen if sanctioned).Don't fulfil.
expiredNothing 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.

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);
}

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.

PurpleRail v0.1.0 · build 39f0527