Merchant guide
Everything you need to accept crypto with MoradaPay: setup, what your customers see, and how to connect your website.
How it works
MoradaPay is non-custodial: your customers pay straight into your own wallet. We never hold your money, so we can't freeze it, lose it or delay it. Our job is to show the customer exactly what to pay, watch the blockchain, and tell your website the moment a payment is confirmed.
- Your website (or you, by hand) creates an invoice for an amount in USD.
- The customer opens the checkout page, picks a coin and network, and pays from any wallet or exchange.
- We detect the payment on-chain, wait for enough confirmations, mark the invoice paid and send your website a signed webhook.
- You deliver the order. The money is already in your wallet.
Payments are in stablecoins (USDC / USDT), so $1 is 1 USDC and you carry no price risk.
Getting started (10 minutes)
- Sign up with email or Google and confirm your email.
- Turn on 2FA under Security (Google Authenticator, Authy…). Save the recovery codes somewhere safe.
- Create an account: choose Merchant for a shop (API + webhooks) or Personal for payment links only. You can have several, e.g. one per website.
- In Settings, paste your wallet address (starts with
0x). The same address works on every supported network. - Tick the coins you accept. All are on by default.
- Merchant accounts: copy your API key (shown once) and set your webhook URL.
Supported coins
| Network | Coins | Confirmations we wait for |
|---|---|---|
| Ethereum | USDC, USDT | 6 |
| Base | USDC, USDT | 10 |
| Polygon | USDC, USDT | 30 |
| BNB Chain | USDC, USDT (18 decimals) | 15 |
| Arbitrum | USDC, USDT | 20 |
| Optimism | USDC, USDT | 10 |
| Avalanche | USDC, USDT | 3 |
"USDT BEP20" is USDT on BNB Chain. "USDT ERC20" is USDT on Ethereum. Ethereum has the highest network fees for your customers, while Base, Polygon, Arbitrum, Optimism and BNB Chain cost a few cents. Confirmation usually takes from a few seconds to about two minutes.
What your customers see
- The price in USD, then a choice of coin and network.
- The exact amount to send (for example
25.004817 USDC), your address, a QR code, copy buttons and a one-click "Pay with browser wallet" button. The last digits make the payment recognisable, so it must be sent exactly. - A 30-minute timer. Payments arriving up to 24 hours late are still recognised.
- A live status that switches to Payment received and, if you set one, sends them back to your website.
Getting paid without code
Invoices from the dashboard
On your account's Overview, enter an amount and description, then send the checkout link to your customer.
Payment links
Under Payment links, create a reusable link with a fixed price ("Logo design $150") or let the payer choose the amount (tips, donations). Share it anywhere. Each visitor gets their own invoice.
Notifications
You get an email for every payment, plus the full history under Invoices and Incoming payments, with CSV export for accounting.
Connect your website
- When the customer clicks "Pay with crypto", your server calls
POST /api/invoiceswith the amount and your order id. - Redirect the customer to the
checkout_urlin the response. - When paid, we
POSTaninvoice.paidwebhook to your server. Check the signature, then mark the order paid. - The customer comes back to your
redirect_url. Show a thank-you page, but don't fulfil the order based on the redirect. Only the webhook, or aGET /api/invoices/{id}check, tells you it's paid.
API reference
Base URL: https://moradapay.com. Send your API key on every request:
Authorization: Bearer sk_your_api_key
Interactive docs: /api/docs. Limit: 1,000 invoices per hour per account.
Create an invoice: POST /api/invoices
{
"amount": "25.00", // USD, required, max 2 decimals
"order_id": "1234", // optional, your reference
"description": "2x T-shirt", // optional, shown to the customer
"redirect_url": "https://shop.com/thanks", // optional, after payment
"coin": "bsc:USDT" // optional: skip the coin choice ("swap" = any token)
}
Response (also the data of every webhook):
{
"id": "6f1c0a9e2b7d4c3a1e5f8b90",
"status": "pending",
"order_id": "1234",
"price_usd": "25.00",
"fee_usd": "0.13", "fee_paid_by": "merchant",
"total_usd": "25.00", // what the customer pays in USD
"coin": null, // e.g. "base:USDC" once chosen
"amount": null, // exact amount to send, e.g. "25.004817"
"received_amount": null, // what actually arrived
"remaining_amount": null, // when underpaid
"overpaid_amount": null,
"pay_address": null,
"tx_hash": null,
"checkout_url": "https://pay.moradapay.com/pay/6f1c0a9e2b7d4c3a1e5f8b90",
"created_at": "...", "expires_at": "...", "paid_at": null
}
Other endpoints
GET /api/invoices/{id}: one invoice.GET /api/invoices?status=paid&limit=50: recent invoices.GET /api/coins: the coins your account accepts.
Invoice statuses
| status | meaning | what you do |
|---|---|---|
pending | waiting for payment | nothing yet |
paid | fully paid and confirmed (incl. small rounding within your tolerance) | deliver the order |
underpaid | part arrived; the customer can still send the rest | wait, or accept / refund in the dashboard |
expired | nothing paid in time | cancel the order (a late payment within 24h still turns it paid) |
refunded | you refunded an underpayment | cancel the order |
Webhooks
Set your URL in Settings (must be https://). We send a JSON POST for these events:
invoice.paid: deliver the order.invoice.underpaid,invoice.expired,invoice.refunded: for your records.test: from the "Send test webhook" button.
Body: {"event": "invoice.paid", "data": { …the invoice… }}. Headers:
X-Gateway-Event: invoice.paid X-Gateway-Timestamp: 1767225600 X-Gateway-Signature: hex( HMAC_SHA256( webhook_secret, timestamp + "." + raw_body ) )
- Always verify the signature with your webhook secret (in Settings), and reject timestamps older than 5 minutes.
- Reply with any
2xxquickly. If not, we retry up to 8 times (after 1, 2, 4, 8… minutes). The log and a "Resend" button are under Webhooks. - The same event can arrive twice, so make your handler idempotent (only fulfil an order once).
Code examples
Create an invoice and redirect
<?php
$ch = curl_init("https://moradapay.com/api/invoices");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("GATEWAY_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode([
"amount" => number_format($order->total, 2, ".", ""),
"order_id" => (string) $order->id,
"redirect_url" => "https://yourshop.com/thanks?order=" . $order->id,
]),
]);
$invoice = json_decode(curl_exec($ch), true);
header("Location: " . $invoice["checkout_url"]);
Webhook handler
<?php
$body = file_get_contents("php://input");
$ts = $_SERVER["HTTP_X_GATEWAY_TIMESTAMP"] ?? "";
$sig = $_SERVER["HTTP_X_GATEWAY_SIGNATURE"] ?? "";
$expected = hash_hmac("sha256", $ts . "." . $body, getenv("GATEWAY_WEBHOOK_SECRET"));
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) { http_response_code(401); exit; }
$event = json_decode($body, true);
if ($event["event"] === "invoice.paid") {
markOrderPaid($event["data"]["order_id"], $event["data"]["id"]); // only once per order
}
http_response_code(200);
Create an invoice and redirect (Express)
app.post("/checkout/crypto", async (req, res) => {
const order = await getOrder(req.body.orderId);
const r = await fetch("https://moradapay.com/api/invoices", {
method: "POST",
headers: { Authorization: "Bearer " + process.env.GATEWAY_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ amount: order.total.toFixed(2), order_id: String(order.id),
redirect_url: "https://yourshop.com/thanks?order=" + order.id }),
});
const invoice = await r.json();
res.redirect(invoice.checkout_url);
});
Webhook handler
const crypto = require("crypto");
app.post("/webhooks/crypto", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.get("X-Gateway-Timestamp"), sig = req.get("X-Gateway-Signature") || "";
const expected = crypto.createHmac("sha256", process.env.GATEWAY_WEBHOOK_SECRET)
.update(ts + "." + req.body).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
if (!fresh || sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.sendStatus(401);
const event = JSON.parse(req.body);
if (event.event === "invoice.paid") markOrderPaid(event.data.order_id, event.data.id); // only once
res.sendStatus(200);
});
Create an invoice and redirect
import os, requests
resp = requests.post("https://moradapay.com/api/invoices", timeout=15,
headers={"Authorization": "Bearer " + os.environ["GATEWAY_API_KEY"]},
json={"amount": f"{order.total:.2f}", "order_id": str(order.id),
"redirect_url": f"https://yourshop.com/thanks?order={order.id}"})
invoice = resp.json()
return redirect(invoice["checkout_url"])
Webhook handler (Flask)
import hmac, hashlib, json, os, time
from flask import request, abort
@app.post("/webhooks/crypto")
def crypto_webhook():
body = request.get_data()
ts = request.headers.get("X-Gateway-Timestamp", "0")
expected = hmac.new(os.environ["GATEWAY_WEBHOOK_SECRET"].encode(), ts.encode() + b"." + body,
hashlib.sha256).hexdigest()
if abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(expected, request.headers.get("X-Gateway-Signature", "")):
abort(401)
event = json.loads(body)
if event["event"] == "invoice.paid":
mark_order_paid(event["data"]["order_id"], event["data"]["id"]) # only once
return "", 200
Wrong amounts & refunds
- Tiny shortfall (customer rounded 25.004817 to 25): counts as paid, within your tolerance (default 0.5%, set in Settings).
- Underpaid: the invoice becomes
underpaid, the checkout asks the customer for the rest, and you get an email. You can also accept it as paid or refund it from the invoice page. - Overpaid: the invoice is paid and flagged, and you can refund the extra.
- Unmatched payments: if we can't tell which invoice a transfer is for (for example two similar invoices were open), it appears under Incoming payments and you assign it with one click.
- Refunds are sent from your wallet, using the button on the invoice page or another wallet (then paste the transaction hash). We confirm them on-chain. Customers can leave a refund address on the checkout. Use it when they paid from an exchange.
Fees
- Platform fee: 0.5% per payment. In Settings you choose who pays it: you, or your customer (added to the checkout price).
- Network ("gas") fees are paid by the customer when they send the payment.
- On networks with automatic splitting, the fee is taken when the payment is forwarded to you, so there's nothing to pay. On other networks it adds up under Fees, and you pay it there in any coin. Accounts pause new invoices if unpaid fees get too high or too old.
Pay with any token
If you enable it in Settings, customers can pay with ETH, BNB, WBTC, POL, AVAX and hundreds of other tokens from 60+ networks. Their token is converted in the same transaction (via LI.FI), and you receive at least the full price in the stablecoin you chose ("receive as"). The customer pays the conversion costs. This needs a browser wallet such as MetaMask or Rabby.
Security checklist
- Turn on 2FA and keep the recovery codes offline.
- Keep the API key and webhook secret on your server only. If one leaks, generate a new one in Settings.
- Verify every webhook signature, and fulfil orders only from
invoice.paid(webhook or API), never from the redirect. - Double-check your wallet address after pasting it, since some malware swaps copied addresses.
- Use a hardware wallet or Safe for larger amounts.
FAQ
The customer sent the right coin on the wrong network.
It still arrives in your wallet if your address exists there, which it does on all EVM networks, but we can't match it to the order automatically. You'll see it in your wallet and can refund it or deliver by hand.
Can I change my wallet address?
Yes, any time in Settings. New invoices use the new address. Open invoices keep the address they were created with.
How fast is it?
As soon as the network confirms: usually seconds on Base, Polygon, Arbitrum, Optimism, Avalanche and BNB Chain, and about a minute or two on Ethereum.
Do I need to hold ETH or BNB?
Not to receive. Only to send a refund, which needs a little of the network's coin for gas.
Is there a test mode?
Test networks are available on development installs only. On this live system everything uses real coins, so try a $1 invoice first.