pay.mntad.com

pay.mntad.com — API v1

USDT payments on TRC20 (Tron) and BEP20 (BNB Smart Chain) for merchants. You get crypto addresses, SamaPay watches the chains, and you get a signed webhook for every confirmed deposit. Money you receive becomes your gateway balance at SamaPay.

This file is checked against the code by scripts/verify-api-docs.ts. Every route, status, error code, event type, header and constant below is compared with src/, so the two cannot drift.


1. Authentication

Authorization: Bearer sk_live_<40 characters>

Scopes

A key carries a set of scopes, and each route needs one of them:

ScopeAllows
payment_intents.writePOST /v1/payment-intents
payment_intents.readGET /v1/payment-intents, GET /v1/payment-intents/:id
addresses.writePOST /v1/addresses
deposits.readGET /v1/deposits, GET /v1/deposits/:id
events.readGET /v1/events/:id
balance.readGET /v1/balance

A missing scope gets 403 insufficient_scope, and error.details.required names the scope.


2. Errors

{ "error": { "code": "amount_out_of_range", "message": "…", "request_id": "req_…", "details": { } } }

Branch on code. message is English text for humans, and it may change.

HTTPcodeWhen
400invalid_jsonThe body is not JSON.
400idempotency_key_requiredA POST without a valid Idempotency-Key (1–255 printable ASCII characters).
400validation_failedWrong shape. details.fields lists the fields.
401unauthenticatedAuthorization is missing or malformed.
401invalid_keyUnknown key, wrong secret, or a test key in production.
401key_revokedThe key was revoked. details.successor is the last 4 characters of its successor, if one exists.
403insufficient_scopedetails.required names the missing scope.
404not_foundUnknown id or an object of another account. The two cases get the same answer.
409idempotency_payload_mismatchThe same Idempotency-Key was used with a different body.
409idempotency_in_progressThe first request with this key is still running. Retry after about 1 s.
409reference_conflictA payment intent with this reference already exists with a different amount or chain.
422amount_out_of_rangeAmount ≤ 0, more than 6 decimals, or outside your account's min/max.
422unsupported_chainThe chain is not TRC20/BEP20, or it is not enabled for your account.
422reference_invalidThe reference's length or characters are not allowed (see each flow).
429rate_limitedToo many requests. Wait for the number of seconds in the Retry-After header.
503derivation_unavailableNo address can be derived right now. Nothing was created, so retry later.
503chain_unavailableThe chain layer is unavailable. Nothing was created.
500internalOur fault. It is logged under request_id.

3. Idempotency (every POST)

Send Idempotency-Key: <1–255 printable ASCII characters> on every POST. Keys are scoped to (your key, Idempotency-Key) and kept for 24 hours.

A replay returns the answer as it was then. Read the current state with a GET.


4. Rate limits


5. Objects

PaymentIntent

{
  "id": "pi_3f9c…",
  "object": "payment_intent",
  "status": "requires_payment",
  "amount": "12.5",
  "amount_received": "0",
  "fee_amount": "0",
  "currency": "USDT",
  "chain": "TRC20",
  "address": "T…",
  "reference": "order-1042",
  "tx_hashes": [],
  "confirmations_required": 19,
  "expires_at": "2026-09-25T13:00:00.000Z",
  "created_at": "2026-09-25T12:00:00.000Z",
  "succeeded_at": null,
  "expired_at": null
}

Statuses:

statusMeaning
requires_paymentWaiting for a transfer.
processingA transfer was seen but has not reached the confirmation depth yet.
succeededConfirmed deposits reached amount in time. Overpayment is allowed (amount_received > amount).
succeeded_lateThe amount was completed after expires_at.
expiredExpired with nothing received.
expired_partialExpired with less than amount received.

Deposit

{
  "id": "dep_cm8x…",
  "object": "deposit",
  "status": "confirmed",
  "chain": "TRC20",
  "tx_hash": "a1b2…",
  "amount": "12.5",
  "confirmations": 20,
  "address": "T…",
  "reference": "order-1042",
  "payment_intent_id": "pi_3f9c…",
  "detected_at": "2026-09-25T12:03:10.000Z",
  "confirmed_at": "2026-09-25T12:04:05.000Z"
}

Amounts, dust, confirmations

ChainConfirmations
TRC2019
BEP2015

The intent's confirmations_required field reports the same number.


6. Flow 1: one payment, one address (payment intents)

Use this for checkout: a fixed amount with an expiry, on a fresh address that is never reused.

POST /v1/payment-intents, scope payment_intents.write.

FieldRule
amountDecimal string, > 0, ≤ 6 decimals, within your account's min/max (default 1 … 10000).
chainTRC20 or BEP20.
referenceYour id for this payment: 1–200 characters of [A-Za-z0-9:_-].
expires_in_secOptional, 300 … 604800. Default 3600.
curl -s https://pay.mntad.com/v1/payment-intents \
  -H "Authorization: Bearer $SAMAPAY_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{"amount":"12.5","chain":"TRC20","reference":"order-1042"}'

Reading intents:

What arrives by webhook:

Read data.object.status to tell the variants apart. **Credit on deposit.confirmed**, and use the intent events only for status and UX.


7. Flow 2: a permanent address per customer (top-up)

Use this for wallets and balances: each customer gets one address for life and can send any amount at any time.

POST /v1/addresses, scope addresses.write.

FieldRule
chainTRC20 or BEP20 (enabled for your account).
referenceclient:tenant:kind:id: four non-empty segments of at most 64 characters, with no :, whitespace or control characters inside a segment. Example: acme:shop1:user:42.
curl -s https://pay.mntad.com/v1/addresses \
  -H "Authorization: Bearer $SAMAPAY_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: addr-acme-shop1-user-42-trc20" \
  -d '{"chain":"TRC20","reference":"acme:shop1:user:42"}'
{ "object": "address", "chain": "TRC20", "address": "T…", "reference": "acme:shop1:user:42" }

8. Reading

RouteScopeReturns
GET /v1/depositsdeposits.read{ "object": "list", "data": [Deposit], "has_more": bool }, oldest first. Query: reference, payment_intent_id, since (ISO-8601), limit (1–100, default 100).
GET /v1/deposits/:iddeposits.readOne Deposit (dep_…).
GET /v1/events/:idevents.readOne Event, exactly as it was delivered.
GET /v1/balancebalance.readYour gateway balance (below).
GET /v1/healthnoneService status.

GET /v1/balance:

{
  "object": "balance",
  "currency": "USDT",
  "chains": { "TRC20": { "available": "15.75", "pending": "0" }, "BEP20": { "available": "0", "pending": "0" } },
  "fee_bps": 0
}

9. Webhooks

SamaPay POSTs each event to your webhook URL (https, set when your key is issued).

Event

{
  "id": "evt_…",
  "object": "event",
  "api_version": "2026-09-24",
  "type": "deposit.confirmed",
  "created_at": "2026-09-25T12:04:05.000Z",
  "data": { "object": { "id": "dep_…", "object": "deposit" } }
}

Headers

The signature is v1 = HMAC-SHA256(secret, "<t>.<raw body>"), in hex. secret is your whole whsec_… string. During a secret rotation there can be several v1 values; accept the request if any of them matches.

Verify (Node)

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the EXACT bytes you received (not re-serialised JSON). secrets: your current whsec_… (and the previous one during a rotation).
export function verifySamaPay(rawBody, header, secrets, now = Math.floor(Date.now() / 1000)) {
  const parts = String(header ?? "").split(",").map((p) => p.trim());
  const ts = parts.filter((p) => p.startsWith("t="));
  if (ts.length !== 1 || !/^t=\d{1,12}$/.test(ts[0])) return false;
  const t = Number(ts[0].slice(2));
  if (Math.abs(now - t) > 300) return false;                       // replay window: 300 s
  const given = parts.filter((p) => /^v1=[0-9a-f]{64}$/.test(p)).map((p) => Buffer.from(p.slice(3), "hex"));
  let ok = false;
  for (const s of secrets) {
    const expected = createHmac("sha256", s).update(`${t}.${rawBody}`).digest();
    for (const g of given) if (g.length === expected.length && timingSafeEqual(g, expected)) ok = true;
  }
  return ok;
}

What your receiver must do

  1. Verify against the raw body. Reject if |now − t| > 300 s. Compare in constant time.
  2. Do not trust the body. Re-fetch the object with your own key and act only on what you fetched:
    • deposit.confirmed: GET /v1/deposits/:id, where :id is data.object.id. Credit only if the fetched status is confirmed.
    • payment_intent.*: GET /v1/payment-intents/:id.
    • Or re-fetch the whole event with GET /v1/events/:id.
  3. Credit each deposit exactly once. Key the credit on the deposit, <CHAIN>:<tx_hash>, with the hash normalised: BEP20 as 0x + 64 lowercase hex, TRC20 as 64 lowercase hex without 0x. The dep_… id is equally unique. Put a unique constraint on that key in your own database. Deliveries are at-least-once, so the same event can arrive more than once.
  4. Credit the fetched amount. If your ledger has fewer decimals, round down.
  5. Answer 2xx only after your write is durable.

Delivery and retries