Test keys (sk_test_…) use BNB Smart Chain testnet. Live keys will use mainnet at launch, with the same API.
Step 1
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
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
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.
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));
}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))
}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']);
}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"])