Accept stablecoin payments on 7 blockchains with a single API call — no Web3 knowledge required.
If you're building a SaaS and want to accept USDT payments — whether it's for international customers dodging wire fees, crypto-native users, or markets where Stripe doesn't reach — this guide is for you.
We'll use IronixPay to add a Stripe-like checkout flow, except the payment happens on-chain. The integration pattern is almost identical: create a session on your server → redirect the user → receive a webhook when payment confirms.
What we'll build:
- A Next.js API route that creates checkout sessions
- A product page with a "Pay with USDT" button
- A webhook handler that verifies signatures and processes payments
- Success and cancel pages
Prerequisites: Node.js 18+, a free IronixPay account
💡 Full source code: github.com/IronixPay/ironixpay-examples
1. Project Setup
npx create-next-app@latest my-store --typescript --app
cd my-store
Create a .env.local file:
# Get your key at https://app.ironixpay.com → Dashboard → API Keys
IRONIXPAY_SECRET_KEY=sk_test_your_key_here
IRONIXPAY_API_URL=https://sandbox.ironixpay.com
NEXT_PUBLIC_APP_URL=http://localhost:3000
That's it for dependencies — we only need fetch, no extra packages.
2. The API Helper
Create src/lib/ironixpay.ts — a lightweight wrapper around the IronixPay API:
// src/lib/ironixpay.ts
export type Network =
| "TRON" | "BSC" | "ETHEREUM"
| "POLYGON" | "ARBITRUM" | "OPTIMISM" | "BASE";
export interface CreateSessionParams {
amount: number; // USDT micro-units (1 USDT = 1,000,000)
currency: "USDT";
network: Network;
success_url: string;
cancel_url: string;
client_reference_id?: string;
}
export interface CheckoutSession {
id: string;
url: string;
status: string;
amount_expected: string;
pay_address: string;
expires_at: string;
}
const API_URL = process.env.IRONIXPAY_API_URL || "https://sandbox.ironixpay.com";
const SECRET_KEY = process.env.IRONIXPAY_SECRET_KEY || "";
export async function createCheckoutSession(
params: CreateSessionParams
): Promise<CheckoutSession> {
const res = await fetch(`${API_URL}/v1/checkout/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(
`IronixPay API error (${res.status}): ${error.message || res.statusText}`
);
}
return res.json();
}
Key concepts:
-
Micro-units: All amounts use the smallest unit.
10.50 USDT = 10_500_000. This avoids floating-point precision issues — the same approach Stripe uses with cents. -
Server-side only: The secret key (
sk_test_...) stays on your server. Never expose it to the client. - 7 networks: TRON, BSC, Ethereum, Polygon, Arbitrum, Optimism, Base. Users pay on their preferred chain.
3. The Checkout API Route
Create src/app/api/checkout/route.ts:
// src/app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { createCheckoutSession } from "@/lib/ironixpay";
import type { Network } from "@/lib/ironixpay";
export async function POST(request: Request) {
try {
const { amount, network = "TRON" } = await request.json() as {
amount: number;
network?: Network;
};
// Validate: minimum 1 USDT
if (!amount || amount < 1_000_000) {
return NextResponse.json(
{ error: "Amount must be at least 1 USDT" },
{ status: 400 }
);
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const session = await createCheckoutSession({
amount,
currency: "USDT",
network,
success_url: `${appUrl}/success`,
cancel_url: `${appUrl}/cancel`,
client_reference_id: `order_${Date.now()}`,
});
return NextResponse.json({ id: session.id, url: session.url });
} catch (error) {
console.error("Checkout error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Internal error" },
{ status: 500 }
);
}
}
This is the equivalent of Stripe's stripe.checkout.sessions.create(). The response includes a url that you redirect the user to.
4. The Product Page
Create your checkout page in src/app/page.tsx:
"use client";
import { useState } from "react";
export default function Home() {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount: 9_990_000, // 9.99 USDT (always use integers, not 9.99 * 1_000_000)
network: "TRON",
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
window.location.href = data.url; // Redirect to IronixPay checkout
} catch (err) {
alert(err instanceof Error ? err.message : "Payment failed");
setLoading(false);
}
};
return (
<main style={{ maxWidth: 480, margin: "80px auto", textAlign: "center" }}>
<h1>My SaaS Product</h1>
<p>Pro Plan — $9.99/month</p>
<button onClick={handleCheckout} disabled={loading}>
{loading ? "Redirecting..." : "Pay $9.99 USDT"}
</button>
</main>
);
}
When the user clicks "Pay", we:
- Call our API route (server-side, secret key safe)
- Get back a hosted checkout URL
- Redirect — IronixPay shows a payment page with QR code and address
- The user pays with any TRON wallet (TronLink, Trust Wallet, etc.)
5. Handling Webhooks
This is where the real work happens. After the user pays, IronixPay sends a webhook to confirm the payment is settled on-chain.
Create src/app/api/webhooks/ironixpay/route.ts:
// src/app/api/webhooks/ironixpay/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("x-signature") || "";
const timestamp = request.headers.get("x-timestamp") || "";
const secret = process.env.IRONIXPAY_WEBHOOK_SECRET || "";
// Step 1: Verify HMAC-SHA256 signature
if (secret) {
const isValid = await verifySignature(body, signature, timestamp, secret);
if (!isValid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
}
// Step 2: Parse and handle the event
const event = JSON.parse(body);
// Webhook event types:
// - session.completed — Payment confirmed on-chain
// - session.expired — Session timed out
switch (event.event_type) {
case "session.completed":
// ✅ Payment is confirmed on-chain — fulfill the order!
const { id, amount_received, client_reference_id } = event.data;
console.log(`Payment ${id} confirmed: ${amount_received} micro-units`);
// TODO: Update your database, activate the subscription, send email
break;
case "session.expired":
// ⏰ Session timed out — no payment received
console.log(`Session ${event.data.id} expired`);
break;
}
return NextResponse.json({ received: true });
}
// HMAC-SHA256 verification
async function verifySignature(
payload: string, signature: string, timestamp: string, secret: string
): Promise<boolean> {
// Reject requests older than 5 minutes (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
// IronixPay signs: "{timestamp}.{payload}"
const message = `${timestamp}.${payload}`;
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
const computed = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, "0")).join("");
return computed === signature;
}
Why verify signatures?
Without verification, anyone could POST fake "payment confirmed" events to your webhook URL. The HMAC-SHA256 signature ensures the request came from IronixPay.
IronixPay uses the pattern HMAC(secret, timestamp + "." + body) — binding the timestamp prevents replay attacks where an attacker resends a captured webhook.
6. Success & Cancel Pages
// src/app/success/page.tsx
export default function SuccessPage() {
return (
<main style={{ maxWidth: 480, margin: "80px auto", textAlign: "center" }}>
<h1>✅ Payment Successful!</h1>
<p>Your USDT payment has been confirmed on-chain.</p>
<a href="/">Back to Store</a>
</main>
);
}
// src/app/cancel/page.tsx
export default function CancelPage() {
return (
<main style={{ maxWidth: 480, margin: "80px auto", textAlign: "center" }}>
<h1>⏰ Payment Cancelled</h1>
<p>The session expired. No payment was processed.</p>
<a href="/">Try Again</a>
</main>
);
}
7. Test It
npm run dev
# Open http://localhost:3000
# Click "Pay" → you'll be redirected to the IronixPay checkout page
In sandbox mode, the checkout page shows a TRON testnet address. You can send test TRX/USDT from a Nile faucet to simulate a payment.
The Complete Flow
Your App IronixPay Blockchain
│ │ │
│── POST /api/checkout ────▶│ │
│ │── Creates session ───────▶│
│◀── { url } ───────────────│ │
│ │ │
│── Redirect user ─────────▶│ │
│ │── Shows QR / address ────▶│
│ │ │
│ │◀── USDT transfer ─────────│
│ │── Verifies on-chain ─────▶│
│ │ │
│◀── Webhook: completed ────│ │
│── Fulfill order │ │
│── Redirect to /success │ │
Going to Production
Three changes:
IRONIXPAY_SECRET_KEY=sk_live_your_production_key
IRONIXPAY_API_URL=https://api.ironixpay.com
IRONIXPAY_WEBHOOK_SECRET=whsec_your_webhook_secret
That's it. Same code, real money.
Why USDT?
| Credit Card (Stripe) | USDT (IronixPay) | |
|---|---|---|
| Fees | 2.9% + $0.30 | 1% flat |
| Settlement | 2-7 days | Instant |
| Chargebacks | Yes (fraud risk) | Impossible |
| Global reach | Limited by country | Anyone with a wallet |
| KYC required | For merchant | No |
For a $100 payment: Stripe takes $3.20, IronixPay takes $1.00. At scale, that difference compounds.
Resources
- 📖 IronixPay Docs
- 🧑💻 Full source code (GitHub)
- 💬 Questions? Telegram
IronixPay supports USDT on TRON, BSC, Ethereum, Polygon, Arbitrum, Optimism, and Base — all through a single API. Get started free →
Top comments (0)