Technical guide

Binance payment verification — Pay ID, Order ID & on-chain

কোন Binance URL গুলো আমরা API key + secret দিয়ে call করি, কীভাবে signature বানাই, Binance Pay ID-তে পেমেন্ট নিয়ে order ID দিয়ে verify করি, on-chain deposit কীভাবে ২% tolerance সহ match হয়, আর timing rule গুলো কী — সবকিছু এক পেজে। শেষে একটা সম্পূর্ণ AI-agent prompt আছে যেটা যেকোনো প্রজেক্টে paste করলেই পুরো সিস্টেম বানিয়ে দেবে।

1. Keys, hosts & request signing

Read-only key যথেষ্ট। Withdraw/Trade permission কখনো দেবেন না। Cloud worker-এ IP whitelist না দেওয়াই ভালো, আর key-তে “No expiry” রাখুন।

text
Base hosts (try in order, failover on 403/429/5xx):
  https://api.binance.com
  https://api1.binance.com
  https://api2.binance.com
  https://api3.binance.com
  https://api4.binance.com
  https://api-gcp.binance.com

Signed request recipe (SIGNED / USER_DATA endpoints):
  query   = "startTime=...&endTime=...&timestamp=<ms>&recvWindow=10000"
  sig     = HMAC_SHA256(API_SECRET, query).hexdigest()
  GET {host}{path}?{query}&signature={sig}
  header  X-MBX-APIKEY: <API_KEY>

Endpoints we use:
  GET /api/v3/account                     -> key health check (permissions + balances)
  GET /sapi/v1/capital/deposit/hisrec     -> on-chain deposit history (crypto)
  GET /sapi/v1/pay/transactions           -> Binance Pay / Pay-ID transaction history

Key permissions required: "Enable Reading" only.
Do NOT enable withdraw/trade. Prefer "No expiry" + no IP whitelist for cloud workers.
python
# binance_client.py  — signed Binance reader (read-only keys)
import hmac, hashlib, time, requests
from urllib.parse import urlencode

HOSTS = [
    "https://api.binance.com", "https://api1.binance.com", "https://api2.binance.com",
    "https://api3.binance.com", "https://api4.binance.com", "https://api-gcp.binance.com",
]

class Binance:
    def __init__(self, key: str, secret: str):
        self.key, self.secret = key, secret

    def _signed(self, path: str, params: dict):
        last = "binance request failed"
        for host in HOSTS:
            q = urlencode({**params, "timestamp": int(time.time() * 1000), "recvWindow": 10000})
            sig = hmac.new(self.secret.encode(), q.encode(), hashlib.sha256).hexdigest()
            r = requests.get(f"{host}{path}?{q}&signature={sig}",
                             headers={"X-MBX-APIKEY": self.key}, timeout=20)
            if r.ok:
                return r.json()
            last = f"{r.status_code}: {r.text[:200]}"
            if r.status_code not in (403, 408, 418, 429, 500, 502, 503, 504):
                break
        raise RuntimeError(last)

    def account(self):
        return self._signed("/api/v3/account", {})

    def deposits(self, since_ms: int):
        data = self._signed("/sapi/v1/capital/deposit/hisrec",
                            {"startTime": since_ms, "endTime": int(time.time() * 1000)})
        return data if isinstance(data, list) else []

    def pay_transactions(self, since_ms: int, limit: int = 100):
        data = self._signed("/sapi/v1/pay/transactions",
                            {"startTime": since_ms, "endTime": int(time.time() * 1000), "limit": limit})
        return data.get("data", []) if isinstance(data, dict) else []

2. Binance Pay (Pay ID) — payment + order-ID verify

Pay ID flow-তে কোনো on-chain address লাগে না। Invoice তৈরি হলে user-কে আপনার Binance Pay ID, ঠিক exact amount আর invoice id (note হিসেবে) দেখানো হয়। User Binance app থেকে পাঠালে সেটা /sapi/v1/pay/transactions-এ চলে আসে, আর সেখানকার transactionId-ই আমাদের Order ID।

json
# GET /sapi/v1/pay/transactions  -> data[] item (Binance Pay / Pay ID)
{
  "orderType": "C2C",                  # PAY | C2C | CRYPTO_BOX | REMITTANCE | TRANSFER | PAY_REFUND | PAYOUT
  "transactionId": "M_P_1234567890123",# <-- this is the ORDER ID you verify against
  "transactionTime": 1755512345678,    # ms epoch — used for the TIMING GATE
  "amount": "1.00000000",              # positive = received, negative = sent
  "currency": "USDT",
  "walletType": 1,
  "payerInfo":    { "binanceId": "123456789", "name": "J***n", "accountType": "USER" },
  "receiverInfo": { "binanceId": "987654321", "name": "Merchant", "accountType": "MERCHANT" }
}
python
# pay_verify.py — Binance Pay ID flow + verify by Order ID (transactionId)
#
# FLOW
#   1. User picks "Binance Pay" -> you create an invoice: amount + currency + expires_at (now+1h)
#   2. You show your Binance Pay ID (e.g. 123456789) + the EXACT amount + the invoice id as a memo
#   3. User pays from their Binance app to your Pay ID
#   4. Poller (every 30s) reads /sapi/v1/pay/transactions and matches by:
#         amount window  +  currency  +  orderType  +  transactionTime >= invoice.created_at - 2min
#   5. First unconsumed match wins -> transactionId is stored as the ORDER ID of that invoice
#   6. Later you can re-verify any invoice by looking its stored transactionId up again
import time

TX_BEFORE_INVOICE_GRACE_MS = 2 * 60 * 1000   # clock-skew allowance
TOLERANCE = 0.02                              # 2% — see notes below

def find_pay_match(pay_txs, invoice, consumed_ids):
    """invoice = {id, amount, currency, created_at_ms}"""
    created = invoice["created_at_ms"]
    min_tx_time = created - TX_BEFORE_INVOICE_GRACE_MS
    same_asset_only = True   # set False to allow FX conversion with tolerance

    for t in pay_txs:
        tx_id = t["transactionId"]
        if tx_id in consumed_ids:                       # 1. dedupe
            continue
        if int(t["transactionTime"]) < min_tx_time:     # 2. TIMING GATE
            continue
        if str(t.get("orderType", "")).upper() not in (
            "PAY", "C2C", "CRYPTO_BOX", "REMITTANCE", "TRANSFER"):
            continue                                    # 3. only incoming payment types
        amt = float(t["amount"])
        if amt <= 0:                                    # 4. ignore outgoing/refunds
            continue
        same = str(t["currency"]).upper() == str(invoice["currency"]).upper()
        if same:
            expected, tol = float(invoice["amount"]), 0.0   # same asset => EXACT amount
        else:
            if same_asset_only:
                continue
            expected = fx_convert(invoice["currency"], t["currency"], float(invoice["amount"]))
            tol = TOLERANCE                                  # cross-asset => 2% tolerance
            if expected is None:
                continue
        if expected * (1 - tol) <= amt <= expected * (1 + tol):
            return {"order_id": tx_id, "paid_amount": amt,
                    "currency": t["currency"], "paid_at_ms": int(t["transactionTime"]),
                    "payer": (t.get("payerInfo") or {}).get("binanceId")}
    return None

def verify_order_id(bnb, order_id: str, lookback_days: int = 30):
    """Re-verify a stored Order ID directly against Binance."""
    since = int(time.time() * 1000) - lookback_days * 86400_000
    for t in bnb.pay_transactions(since):
        if t["transactionId"] == order_id:
            return {"found": True, "amount": t["amount"], "currency": t["currency"],
                    "time": t["transactionTime"], "type": t["orderType"]}
    return {"found": False}

নোট: same-asset Binance Pay payment-এ tolerance 0% (exact amount) রাখা হয় — কারণ Pay-তে network fee কাটে না, তাই exact match-ই নিরাপদ। শুধু currency আলাদা হলে (FX conversion) ২% tolerance প্রযোজ্য।

3. On-chain deposits — 2% tolerance matching

On-chain-এ user সবসময় exact amount পাঠাতে পারে না (fee/rounding), তাই ২% window ব্যবহার হয়।

json
# GET /sapi/v1/capital/deposit/hisrec  -> array item (on-chain)
{
  "amount": "10.00000000",
  "coin": "USDT",
  "network": "BSC",                 # TRX | BSC | ETH | SOL ...
  "address": "0xabc...def",         # must equal your invoice wallet address
  "addressTag": "",
  "txId": "0x9f3c...",              # unique -> dedupe key
  "insertTime": 1755512345678,      # ms epoch -> timing gate
  "status": 1                       # 0 pending, 6 credited-but-cannot-withdraw, 1 SUCCESS (only 1 counts)
}
python
# crypto_verify.py — on-chain deposit matching with 2% tolerance
TOLERANCE = 0.02                       # 2%
TX_BEFORE_INVOICE_GRACE_MS = 2 * 60 * 1000

def find_deposit_match(deposits, invoice, wallet, consumed_tx_ids):
    """
    invoice = {amount, currency, created_at_ms}
    wallet  = {address, currency, network}
    """
    target = float(invoice["amount"])
    if wallet["currency"].upper() != invoice["currency"].upper():
        target = fx_convert(invoice["currency"], wallet["currency"], target)
        if target is None:
            return None
    lo, hi = target * (1 - TOLERANCE), target * (1 + TOLERANCE)
    min_tx_time = invoice["created_at_ms"] - TX_BEFORE_INVOICE_GRACE_MS

    for d in deposits:
        if d["txId"] in consumed_tx_ids:                       continue  # dedupe
        if int(d["insertTime"]) < min_tx_time:                 continue  # timing gate
        if d.get("status") != 1:                               continue  # confirmed only
        if d["address"].lower() != wallet["address"].lower():  continue  # right wallet
        if d["coin"].upper() != wallet["currency"].upper():    continue  # right coin
        amt = float(d["amount"])
        if lo <= amt <= hi:                                    # 2% window
            return {"tx_id": d["txId"], "paid_amount": amt,
                    "network": d["network"], "paid_at_ms": int(d["insertTime"])}
    return None

4. Timing rules — কেন পুরনো invoice নতুন payment খেতে পারে না

  • Newest-first scan: pending invoice গুলো created_at DESC order-এ scan হয়।
  • Timing gate: tx time < invoice.created_at − 2 min হলে সেই tx ওই invoice-এ কখনো match হবে না।
  • Dedupe lock: consumed_transactions(user_id, source, tx_id) unique — এক tx শুধু এক invoice settle করবে।
  • Auto-expire: API-created invoice ১ ঘণ্টা পরে expired, webhook invoice.expired fire হয়।
  • Lookback: deposits 24 ঘণ্টা, Pay history 30 দিন; poll 30s বা 1-min cron.

5. Full poller (copy-paste)

python
# poller.py — run forever, credit balance the moment a payment lands
# Rules enforced here (same as production):
#   * newest invoice first  (created_at DESC) so a fresh invoice claims a fresh payment
#   * timing gate: tx older than invoice.created_at - 2min is ignored
#   * dedupe: one Binance tx can settle exactly ONE invoice (unique index on tx_id)
#   * expiry: API invoices auto-expire 1 hour after creation
import time
from binance_client import Binance

POLL_SECONDS   = 30
INVOICE_TTL_S  = 60 * 60         # 1 hour
LOOKBACK_MS    = 24 * 3600_000   # deposits window
PAY_LOOKBACK_MS= 30 * 86400_000  # pay history window

def tick(bnb, db):
    now_ms = int(time.time() * 1000)
    # 0) expire stale invoices first
    db.expire_invoices(older_than_ms=now_ms - INVOICE_TTL_S * 1000)

    pending  = db.pending_invoices_newest_first()
    if not pending:
        return
    deposits = bnb.deposits(now_ms - LOOKBACK_MS)
    pay_txs  = bnb.pay_transactions(now_ms - PAY_LOOKBACK_MS)
    used_tx  = db.consumed_tx_ids()

    for inv in pending:
        wallet = db.wallet_of(inv)
        if wallet["network"] == "BINANCE_PAY":
            m = find_pay_match(pay_txs, inv, used_tx)
            tx = m and m["order_id"]
        else:
            m = find_deposit_match(deposits, inv, wallet, used_tx)
            tx = m and m["tx_id"]
        if not m:
            db.log(inv["id"], matched=False, notes="no match this cycle")
            continue
        if not db.claim_tx(tx, inv["id"]):    # atomic INSERT, unique(tx_id) -> race safe
            continue
        used_tx.add(tx)
        db.mark_paid(inv["id"], tx_hash=tx, paid_amount=m["paid_amount"])
        db.credit_user(inv["user_id"], m["paid_amount"])
        db.send_webhook(inv, event="invoice.paid", tx_hash=tx)

if __name__ == "__main__":
    bnb = Binance(KEY, SECRET)
    while True:
        try: tick(bnb, db)
        except Exception as e: print("[poller]", e)
        time.sleep(POLL_SECONDS)

6. Safety checklist

  • শুধু status == 1 deposit accept করুন (0 = pending, 6 = credited-but-locked)।
  • Negative/refund Pay amount reject করুন।
  • Balance credit সবসময় tx claim সফল হওয়ার পরে — আগে নয়।
  • Key secret কখনো log/response-এ যাবে না; সব verification server-side।

7. AI agent prompt — যেকোনো প্রজেক্টে paste করুন

এই prompt-টা Lovable / Cursor / Replit / Bolt যেকোনো AI builder-কে দিলে সে পুরো verification system বানিয়ে দেবে।

prompt
You are implementing a Binance-backed crypto payment verification system inside my project.
Build it end-to-end, production ready, no placeholders. Follow this spec EXACTLY.

## 0. Credentials
Read-only Binance API key + secret (permission: "Enable Reading" only; no withdraw, no trade).
Store as env vars: BINANCE_API_KEY, BINANCE_API_SECRET. Never log them.

## 1. Signed Binance requests
For every SIGNED endpoint:
  query = urlencode(params + {"timestamp": now_ms, "recvWindow": 10000})
  signature = HMAC_SHA256(BINANCE_API_SECRET, query) as lowercase hex
  GET {host}{path}?{query}&signature={signature}   header: X-MBX-APIKEY: {BINANCE_API_KEY}
Host failover list (retry next host on 403/408/418/429/5xx, stop on other errors):
  api.binance.com, api1..api4.binance.com, api-gcp.binance.com
Endpoints:
  /api/v3/account                  -> key health / permissions check
  /sapi/v1/capital/deposit/hisrec  -> on-chain deposits (params: startTime, endTime)
  /sapi/v1/pay/transactions        -> Binance Pay history (params: startTime, endTime, limit=100)

## 2. Data model (create these tables)
invoices(id, user_id, amount numeric, currency text, wallet_id, status text,
         tolerance_pct numeric default 2, created_at timestamptz default now(),
         expires_at timestamptz, tx_hash text, paid_amount numeric, paid_at timestamptz)
wallets(id, user_id, address text, currency text, network text)   -- network 'BINANCE_PAY' for Pay ID
consumed_transactions(id, user_id, invoice_id, source text, tx_id text, amount, currency,
         consumed_at default now(), UNIQUE(user_id, source, tx_id))   -- the anti-double-credit lock
verification_runs(id, invoice_id, user_id, matched bool, notes text, created_at)

## 3. Binance Pay (Pay ID) flow — implement first
a) User chooses "Binance Pay" -> create invoice with amount, currency, expires_at = now + 1 hour.
b) Show: my Binance Pay ID, the EXACT amount, the invoice id (as note/memo), and a countdown.
c) Verification loop reads /sapi/v1/pay/transactions (last 30 days) and matches a tx to an invoice when ALL hold:
   - tx.transactionId not in consumed_transactions           (dedupe)
   - tx.transactionTime >= invoice.created_at - 120000 ms    (TIMING GATE, 2 min clock skew)
   - tx.orderType in [PAY, C2C, CRYPTO_BOX, REMITTANCE, TRANSFER]
   - float(tx.amount) > 0                                     (ignore refunds/outgoing)
   - same currency  -> amount must equal invoice.amount EXACTLY (tolerance 0)
     different currency -> convert via price feed, then allow +/- 2% tolerance
d) On match: store tx.transactionId as the invoice's ORDER ID (tx_hash), mark paid, credit balance,
   fire webhook invoice.paid.
e) Provide a "verify by order id" function: given a stored transactionId, re-fetch pay history and
   return the live tx (amount, currency, time, orderType) so support can re-confirm any payment.

## 4. On-chain crypto verification
Match a deposit to an invoice when ALL hold:
   - d.txId not in consumed_transactions
   - d.insertTime >= invoice.created_at - 120000 ms
   - d.status == 1  (confirmed; 0 = pending, 6 = credited-cannot-withdraw -> reject both)
   - d.address == wallet.address (case-insensitive)
   - d.coin == wallet.currency
   - target = invoice.amount (converted to wallet currency if different)
     accept when  target*0.98 <= d.amount <= target*1.02      (2% TOLERANCE)

## 5. Ordering, timing and expiry rules (critical, do not skip)
- Scan pending invoices NEWEST FIRST (created_at DESC) so a new invoice claims a new payment
  instead of an older one absorbing it.
- Timing gate everywhere: a transaction that happened before the invoice existed (minus 2 min skew)
  can NEVER settle that invoice.
- One transaction settles exactly ONE invoice: claim it by INSERTing into consumed_transactions
  first; if the unique constraint fires, skip (another invoice already claimed it).
- API-created invoices expire 1 hour after creation (or at expires_at). A cron marks them
  status='expired' and fires webhook invoice.expired. Manually created invoices are not auto-expired.
- Deposit lookback 24h, Pay lookback 30d, poll interval 30 seconds (or a 1-minute cron).

## 6. Observability
Write a verification_runs row on every attempt with a human-readable reason, e.g.
  "No confirmed BSC deposit to 0xab12…cd34; expected 9.80000000-10.20000000 USDT; scanned 37 deposits"
  "Binance Pay found 12 tx but none matched invoice 1 USDT; recent: 0.5 USDT C2C, 2 USDT PAY"
Expose a health endpoint that calls /api/v3/account and reports key status + last successful sync.

## 7. Webhooks out (IPN)
POST JSON to the merchant URL with header
  X-Signature: HMAC_SHA256(webhook_secret, RAW_REQUEST_BODY) hex
Events: invoice.paid, invoice.expired, invoice.cancelled, ping.
Retry with exponential backoff (6 attempts over 24h) on non-2xx.

## 8. Deliverables
Working code + migrations + a runnable poller/cron + README with env vars and a manual test:
create a 1 USDT invoice, send 1 USDT, confirm it flips to paid within 60 seconds and the
balance is credited exactly once.

8. Quick key health check

bash
python - <<'PY'
from binance_client import Binance
b = Binance("YOUR_KEY", "YOUR_SECRET")
a = b.account()
print("canTrade:", a.get("canTrade"), "| permissions:", a.get("permissions"))
print("non-zero balances:", [x for x in a["balances"] if float(x["free"]) + float(x["locked"]) > 0][:5])
print("deposits(24h):", len(b.deposits(__import__("time").time_ns()//1_000_000 - 86400000)))
PY