DEV Community

IronixPay
IronixPay

Posted on

Next.js + USDT: Add Crypto Payments to Your SaaS in 15 Minutes

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

When the user clicks "Pay", we:

  1. Call our API route (server-side, secret key safe)
  2. Get back a hosted checkout URL
  3. Redirect — IronixPay shows a payment page with QR code and address
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

7. Test It

npm run dev
# Open http://localhost:3000
# Click "Pay" → you'll be redirected to the IronixPay checkout page
Enter fullscreen mode Exit fullscreen mode

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     │                            │
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 supports USDT on TRON, BSC, Ethereum, Polygon, Arbitrum, Optimism, and Base — all through a single API. Get started free →

Top comments (0)