Accept USDT in three steps

Test keys (sk_test_…) use BNB Smart Chain testnet. Live keys will use mainnet at launch, with the same API.

Step 1

Create an invoice

Every POST needs an Idempotency-Key. Retrying with the same key returns the same invoice, so a timeout never creates a second one. Amounts are decimal strings.

curl https://api.pay.mals.app/v1/invoices \
  -H "Authorization: Bearer sk_test_…" \
  -H "Idempotency-Key: order-4817" \
  -d '{
    "amount": "25",
    "external_ref": "order-4817",
    "redirect_url": "https://shop.example/orders/4817",
    "expires_in": 3600
  }'

The response has deposit_address, total (amount plus the network fee) and checkout_url.

Step 2

Send the customer to checkout

Redirect to checkout_url. It shows the QR code, the exact total, a countdown and the live status, then returns to your redirect_url once paid. Or build your own page with GET /v1/invoices/:id.

Step 3

Verify the webhook, then ship

Each webhook has a Webhook-Id (deliveries can repeat, so dedupe on it) and a Webhook-Signature: t=…,v1=… header: an HMAC SHA256 of t + "." + body with your secret. Reject anything older than 5 minutes.

JavaScript

import crypto from "node:crypto";

// Use the RAW request body, before any JSON parsing.
export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret)
    .update(parts.t + "." + rawBody).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Go

func Verify(rawBody []byte, header string, secret []byte) bool {
	var t, v1 string
	for _, p := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(p, "=")
		if k == "t" { t = v } else if k == "v1" { v1 = v }
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return false
	}
	m := hmac.New(sha256.New, secret)
	m.Write([]byte(t + "."))
	m.Write(rawBody)
	return hmac.Equal([]byte(hex.EncodeToString(m.Sum(nil))), []byte(v1))
}

PHP

function verify(string $rawBody, string $header, string $secret): bool {
    parse_str(str_replace(',', '&', $header), $p);
    $expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
    return abs(time() - (int)$p['t']) < 300 && hash_equals($expected, $p['v1']);
}

Python

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: bytes) -> bool:
    p = dict(kv.split("=", 1) for kv in header.split(","))
    expected = hmac.new(secret, p["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return abs(time.time() - int(p["t"])) < 300 and hmac.compare_digest(expected, p["v1"])

Events

invoice.created
The invoice exists and has a deposit address.
invoice.detected
A payment was seen on chain. Not final yet: don't ship on this.
invoice.confirmed
Paid (within your tolerance) and confirmed 30 blocks deep. Ship the order.
invoice.overpaid
Confirmed with more than the total. The extra goes to your wallet.
invoice.underpaid
Expired with part of the amount received. Decide in your dashboard.
invoice.expired
Expired with nothing received.
invoice.late_paid
Money arrived after expiry, within 30 days. It is still forwarded to you.
invoice.forwarded
The contract split the funds and your share is in your wallet.
Back to the home page