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” রাখুন।
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=...×tamp=<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.# 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।
# 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" }
}# 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 ব্যবহার হয়।
# 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)
}# 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 None4. Timing rules — কেন পুরনো invoice নতুন payment খেতে পারে না
- Newest-first scan: pending invoice গুলো
created_at DESCorder-এ 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, webhookinvoice.expiredfire হয়। - Lookback: deposits 24 ঘণ্টা, Pay history 30 দিন; poll 30s বা 1-min cron.
5. Full poller (copy-paste)
# 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 == 1deposit 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 বানিয়ে দেবে।
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
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