Accept stablecoin payments.
Keep control of the funds.
MainPay creates exact-amount invoices, watches merchant-controlled wallets, matches on-chain transfers, and sends signed webhooks. Test the complete flow without moving real funds.
Drop-in checkout
Create on your server. Pay in the customer's wallet.
The raw REST API works with built-in fetch; no SDK is required. Your front-end receives only the public hosted URL. For credit top-ups, your application owns and updates the balance after a verifiedinvoice.paid.
// Server only — MAINPAY_API_KEY never goes to the browser.
const response = await fetch("https://api.mainpay.com/v1/invoices", {
method: "POST",
headers: {
"X-API-Key": process.env.MAINPAY_API_KEY,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json"
},
body: JSON.stringify({
customer_name: "user_42",
description: "2,500 AI credits",
fiat_amount: "25.00",
asset: "USDC",
network: "base",
receiving_account_id: process.env.MAINPAY_WALLET_ID,
client_reference_id: "user_42",
metadata: { credits: 2500 },
purpose: "credit_topup"
})
});
if (!response.ok) throw new Error(`MainPay error: ${response.status}`);
const order = await response.json();
// Return only order.hosted_url to the browser.The hosted checkout prompts the payer's own wallet to sign and broadcast the exact ERC-20 transfer. MainPay never sees a key and never owns the payment or credit balance.
Quickstart
From test key to paid invoice
Before you begin, connect a Base USDC wallet, create anmp_test_… key, and configure a public HTTPS webhook in the dashboard. The examples require curl and jq.
Set your environment
Use test mode while integrating. Test objects are structurally isolated from live settlement and accounting.
export MAINPAY_API_BASE=https://api.mainpay.com/v1
export MAINPAY_API_KEY=mp_test_xxxxxxxxxxxxxxxxxxxxxxxx
export MAINPAY_WALLET_ID=1de70609-a510-4fb9-8691-61a8d2c21a34Find a receiving wallet
Choose the public_id for an active wallet matching the asset and network you plan to invoice.
curl --fail-with-body -s "$MAINPAY_API_BASE/wallets" \
-H "X-API-Key: $MAINPAY_API_KEY" | jqCreate an invoice
Use a unique idempotency key for each distinct create request. Reuse the same key and body for transport retries.
export IDEMPOTENCY_KEY="quickstart-$(date +%s)-$RANDOM"
ORDER_JSON=$(
curl --fail-with-body -sX POST "$MAINPAY_API_BASE/invoices" \
-H "X-API-Key: $MAINPAY_API_KEY" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg wallet "$MAINPAY_WALLET_ID" '{
description: "API services - quickstart",
fiat_amount: "500.00",
asset: "USDC",
network: "base",
receiving_account_id: $wallet,
customer_name: "Acme Corp",
customer_email: "ap@acme.example",
client_reference_id: "builder-user-42",
metadata: { credits: 2500 },
purpose: "credit_topup"
}')"
)
export INVOICE_ID=$(printf '%s' "$ORDER_JSON" | jq -r '.public_id')
printf '%s\n' "$ORDER_JSON" | jq{ "public_id": "…", "livemode": false, "status": "pending", "crypto_amount": "500.00", "hosted_url": "https://mainpay.com/pay/…" }Simulate detection and settlement
The simulator exercises the same transition and event services as live payments without writing live chain evidence.
curl --fail-with-body -sX POST \
"$MAINPAY_API_BASE/test/invoices/$INVOICE_ID/pay" \
-H "X-API-Key: $MAINPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scenario":"payment_detected"}' | jq
curl --fail-with-body -sX POST \
"$MAINPAY_API_BASE/test/invoices/$INVOICE_ID/pay" \
-H "X-API-Key: $MAINPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scenario":"exact"}' | jqRetrieve authoritative state
Webhooks are a nudge. Retrieve the invoice whenever your integration needs the freshest state.
curl --fail-with-body -s \
"$MAINPAY_API_BASE/invoices/$INVOICE_ID" \
-H "X-API-Key: $MAINPAY_API_KEY" | jqOptional convenience
Use the typed Node SDK.
Install @mainpay/node v0.1.0 with npm i @mainpay/node. It wraps the same REST API, and the raw fetch example above remains a fully supported integration path.
// Optional typed convenience: @mainpay/node v0.1.0
// npm i @mainpay/node
// Server only — MAINPAY_API_KEY never goes to the browser.
import MainPay from "@mainpay/node";
const mainpay = new MainPay(process.env.MAINPAY_API_KEY);
const order = await mainpay.orders.create({
customer_name: "user_42",
description: "2,500 AI credits",
fiat_amount: "25.00",
asset: "USDC",
network: "base",
receiving_account_id: process.env.MAINPAY_WALLET_ID,
client_reference_id: "user_42",
metadata: { credits: 2500 },
purpose: "credit_topup"
});
// Return only order.hosted_url to the browser.
// import { openCheckout } from "@mainpay/node/checkout";
// openCheckout(order.hosted_url);
// In your webhook, verify raw bytes before granting access:
const event = mainpay.webhooks.constructEvent(
rawBody, signatureHeader, process.env.MAINPAY_WEBHOOK_SECRET
);
if (event.type === "invoice.paid") {
await grantCreditsOnce(event.id, event.data.invoice);
}Authentication
One key, one workspace, one mode
mp_test_…Test keys
Create isolated test invoices, simulate all payment outcomes, and receive livemode:false events.
mp_live_…Live keys
Monitor real settlement to your own wallets. Live access is enabled only after the test pilot passes.
X-API-Key: mp_test_xxxxxxxxxxxxxxxxxxxxxxxxKeys are shown once, stored hashed, scoped, immediately revocable, and isolated by workspace and mode. Never expose one in browser code.
Endpoint reference
A deliberately small API surface
/invoicesCreate an invoice201/invoicesList invoices with cursor pagination200/invoices/{public_id}Retrieve authoritative invoice state200/invoices/{public_id}/cancelCancel an open invoice200/checkout/{public_id}Retrieve the payer-safe checkout view200/checkout/{public_id}/eventsRecord an anonymous checkout step202/walletsList active receiving wallets200/test/invoices/{public_id}/paySimulate a test payment200API behavior
Safe defaults for payment operations
Decimal strings
Money is always serialized as a string, never a floating-point number.
Idempotent creates
Identical retries return the original invoice. A changed body returns 409.
Cursor pagination
Lists return data,has_more, and next_cursor.
Per-key limits
100 requests per rolling minute with a 20-request-per-second burst.
Request identity
Every response carriesX-Request-Id for support and tracing.
Payer-safe checkout
Public checkout excludes customer email, internal IDs, and payment history.
Webhooks
Verify first. Process once.
MainPay signs the exact raw request body usingt=<unix>,v1=<hmac>. Reject stale timestamps, compare in constant time, and deduplicate on the stable event id.
import crypto from "node:crypto";
export function verifyMainPaySignature(rawBody, header, secret) {
try {
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=", 2))
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) ||
Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest("hex");
const supplied = parts.v1 ?? "";
const a = Buffer.from(expected);
const b = Buffer.from(supplied);
return a.length === b.length && crypto.timingSafeEqual(a, b);
} catch {
return false;
}
}Event types
invoice.createdInvoice is ready for paymentinvoice.payment_detectedMatching transfer awaits confirmationinvoice.paidExact or accepted payment confirmedinvoice.underpaidConfirmed amount is below acceptanceinvoice.overpaidConfirmed amount exceeds the invoiceinvoice.expiredOpen invoice reached its expiryinvoice.cancelledMerchant stopped collectionDelivery is at least once and unordered. Retries use persisted backoff. Treat the embedded invoice as a snapshot and retrieval as authoritative.
Errors
One predictable envelope
{
"error": {
"type": "invalid_request_error",
"code": "not_found",
"message": "Invoice not found",
"param": null
}
}Controlled pilot
Ready to test your integration?
Connect a wallet, configure a webhook, and ask MainPay to enable a test credential for your workspace. Live access follows successful test evidence.