API v1 Non-custodial by design

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.

MainPay never takes custody.No capture, payout, split, escrow, or gateway-executed refund. Payments settle directly to a wallet your business controls.

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.

typescript
// 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.

01

Set your environment

Use test mode while integrating. Test objects are structurally isolated from live settlement and accounting.

bash
export MAINPAY_API_BASE=https://api.mainpay.com/v1
export MAINPAY_API_KEY=mp_test_xxxxxxxxxxxxxxxxxxxxxxxx
export MAINPAY_WALLET_ID=1de70609-a510-4fb9-8691-61a8d2c21a34
02

Find a receiving wallet

Choose the public_id for an active wallet matching the asset and network you plan to invoice.

bash
curl --fail-with-body -s "$MAINPAY_API_BASE/wallets" \
  -H "X-API-Key: $MAINPAY_API_KEY" | jq
03

Create an invoice

Use a unique idempotency key for each distinct create request. Reuse the same key and body for transport retries.

bash
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
201{ "public_id": "…", "livemode": false, "status": "pending", "crypto_amount": "500.00", "hosted_url": "https://mainpay.com/pay/…" }
04

Simulate detection and settlement

The simulator exercises the same transition and event services as live payments without writing live chain evidence.

bash
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"}' | jq
05

Retrieve authoritative state

Webhooks are a nudge. Retrieve the invoice whenever your integration needs the freshest state.

bash
curl --fail-with-body -s \
  "$MAINPAY_API_BASE/invoices/$INVOICE_ID" \
  -H "X-API-Key: $MAINPAY_API_KEY" | jq

Optional 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.

typescript
// 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.

http
X-API-Key: mp_test_xxxxxxxxxxxxxxxxxxxxxxxx

Keys 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

POST/invoicesCreate an invoice201
GET/invoicesList invoices with cursor pagination200
GET/invoices/{public_id}Retrieve authoritative invoice state200
POST/invoices/{public_id}/cancelCancel an open invoice200
GET/checkout/{public_id}Retrieve the payer-safe checkout view200
POST/checkout/{public_id}/eventsRecord an anonymous checkout step202
GET/walletsList active receiving wallets200
POST/test/invoices/{public_id}/paySimulate a test payment200
OpenAPI 3.1 specificationDownload YAML →

API behavior

Safe defaults for payment operations

01

Decimal strings

Money is always serialized as a string, never a floating-point number.

02

Idempotent creates

Identical retries return the original invoice. A changed body returns 409.

03

Cursor pagination

Lists return data,has_more, and next_cursor.

04

Per-key limits

100 requests per rolling minute with a 20-request-per-second burst.

05

Request identity

Every response carriesX-Request-Id for support and tracing.

06

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.

javascript
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;
  }
}
1 Read raw bytes2 Verify signature3 Store event ID4 Return 2xx quickly

Event types

invoice.createdInvoice is ready for payment
invoice.payment_detectedMatching transfer awaits confirmation
invoice.paidExact or accepted payment confirmed
invoice.underpaidConfirmed amount is below acceptance
invoice.overpaidConfirmed amount exceeds the invoice
invoice.expiredOpen invoice reached its expiry
invoice.cancelledMerchant stopped collection

Delivery 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

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "not_found",
    "message": "Invoice not found",
    "param": null
  }
}
400Invalid requestCorrect the request before retrying.
401AuthenticationCheck that the API key is present and active.
403PermissionUse the correct mode and credential scope.
404Not foundThe object is absent or outside this workspace/mode.
409ConflictRetry an idempotent request with the same key and body.
422ValidationInspect the field-level error list.
429Rate limitWait for Retry-After, then retry with jitter.
503Creation pausedHonor Retry-After; retrieval remains available.

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.

Request pilot accessOpen dashboard