Bắt đầu nhanh
Cổng thanh toán USDT đa chuỗi. Tích hợp server-to-server: nhận tiền (cash-in) và chi trả (cash-out) trên TRC20, BEP20, POLYGON, TON.
- Base URL:
https://api.vinxpay.site— surface tích hợp/api/v1/*(ký HMAC). - Tiền tệ on-chain: chỉ USDT. Mọi số tiền là string (dùng BigNumber/Decimal, đừng parse float).
- Timestamp ISO 8601 UTC (hậu tố
Z).
Kiến trúc
- Tạo đơn = gọi signed API từ backend merchant (nơi giữ secret). Không để secret trên trình duyệt.
- Trang checkout chỉ hiển thị địa chỉ + số tiền + QR, poll trạng thái qua endpoint public.
- Kết quả cuối (SUCCESS/FAILED) nhận qua webhook.
Các bước
- Admin duyệt tài khoản → nhận
keyId+secret(secret chỉ hiện 1 lần). - Đặt PIN 6 số ở Bảo mật, cấu hình URL webhook.
GET /api/v1/balanceđể xác nhận ký HMAC hoạt động.POST /api/v1/cash-in→ nhậnpay_address→ khách chuyển USDT → lắng nghepayment.success.- Khi rút:
POST /api/v1/cash-outkèmpin→ lắng nghepayout.success.
Envelope chung:
// OK: { "success": true, "data": { ... } }
// Lỗi: { "success": false, "code": "...", "message": "...", "details": {} }
code, không theo message (message có thể đổi/đa ngôn ngữ).Xác thực HMAC
Mỗi request tới /api/v1/* mang 4 header. HMAC-SHA256, output hex.
| Header | Mô tả |
|---|---|
X-Api-Key | keyId (vinx_<32hex>) |
X-Timestamp | Unix giây, sai số ±300s so với server |
X-Nonce | Chuỗi duy nhất mỗi request (uuid v4), nhớ 600s |
X-Signature | hex(HMAC-SHA256(secret, "${ts}.${METHOD}.${path}.${rawBody}")) |
Idempotency-Key | (POST) trùng key → trả kết quả cũ |
METHODviết HOA;pathgồm cả/api/v1và query string.rawBody= đúng chuỗi JSON sẽ gửi (rỗng nếu GET). Ký đúng chuỗi rồi gửi đúng chuỗi đó.
const crypto = require('crypto');
const BASE = 'https://api.vinxpay.site';
async function call(method, path, body, KEY_ID, SECRET) {
const rawBody = body ? JSON.stringify(body) : '';
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID();
const sig = crypto.createHmac('sha256', SECRET)
.update(`${ts}.${method.toUpperCase()}.${path}.${rawBody}`).digest('hex');
return fetch(BASE + path, { method, headers: {
'X-Api-Key': KEY_ID, 'X-Timestamp': ts, 'X-Nonce': nonce,
'X-Signature': sig, 'Content-Type': 'application/json',
}, body: method === 'GET' ? undefined : rawBody });
}
Lấy số dư
Headers bắt buộc (signed)
X-Api-Key: vinx_...
X-Timestamp: <unix giây>
X-Nonce: <uuid duy nhất>
X-Signature: hex(HMAC-SHA256(secret, "ts.GET.path.")) # rawBody rỗngSố dư USDT + gas theo network. Query tùy chọn ?currency=USD|VND|EUR để quy đổi fiat tạm thời.
{ "success": true, "data": {
"totalUsdt": "12345.67",
"totalUsdtAvailable": "12000.00", "totalUsdtLocked": "345.67", "totalUsdtRisk": "49.75",
"preferredCurrency": "VND", "rate_at_query": "26287",
"totalAvailable_fiat": "315444000", "totalLocked_fiat": "9088000", "totalRisk_fiat": "1308000",
"items": [
{ "network": "BEP20", "asset": "USDT", "available": "100.00", "locked": "0", "risk": "0",
"available_fiat": "2628700", "locked_fiat": "0", "risk_fiat": "0" },
{ "network": "BEP20", "asset": "BNB", "available": "0.05", "available_fiat": null }
]
}}
totalUsdt = available + locked.risk= USDT từ orphan (chưa khớp đơn), không cộng vào tổng.- Field
*_fiatper-item chỉ có với USDT; item gas trảnull.
Lấy API key (trong Dashboard)
API key (keyId + secret) dùng để ký HMAC mọi request /api/v1. Toàn bộ thao tác cấu hình dưới đây làm trong VINX Dashboard merchant, KHÔNG có API tích hợp để tạo/thu hồi key — đừng tích hợp thao tác key qua API.
- Tạo / thu hồi API key: chỉ trên Dashboard.
secretchỉ hiện 1 lần lúc tạo — lưu lại ngay; mất thì tạo key mới rồi thu hồi key cũ (cũng trên Dashboard). - Set PIN 6 số (không tuần tự) — bắt buộc cho cash-out. Đặt trong Dashboard.
- Cấu hình webhook URL — trong Dashboard (xem mục Webhook).
- IP whitelist theo từng key (tùy chọn) — trong Dashboard.
/api/merchant/api-keys) là API dashboard (auth bằng JWT), không phải API tích hợp — integrator chỉ có API key/secret nên không gọi được. Tích hợp chỉ cần keyId + secret lấy từ Dashboard.Tạo đơn nạp — cash-in
Flow 5 bước
- Merchant server gọi
POST /api/v1/cash-in(network, amount_usdt hoặc amount_fiat+fiat_currency, order_ref, expires_in). - BE trả
201: order_id, pay_address, pay_link, amount_usdt CÓ TAG (vd 10.004873), expires_at, status PENDING. - Merchant hiện pay_link/QR cho khách. Khách gửi đúng số amount_usdt (cả phần tag) tới pay_address.
- BE phát hiện on-chain: PENDING → DETECTED → CONFIRMING → SUCCESS.
- BE bắn webhook ở mỗi bước (
payment.pending → detected → confirming → success) — không chỉ lúc xong. Song song: trang trả tiền của khách pollGET /api/public/payments/:idđể cập nhật UI.
Headers bắt buộc (signed)
X-Api-Key: vinx_...
X-Timestamp: <unix giây>
X-Nonce: <uuid duy nhất>
X-Signature: hex(HMAC-SHA256(secret, "ts.POST.path.rawBody"))
Idempotency-Key: <chuỗi tuỳ ý>
Content-Type: application/jsonTạo invoice nạp USDT. Định giá bằng amount_usdt hoặc amount_fiat + fiat_currency.
Body
{
"network": "BEP20", // TRC20 | BEP20 | POLYGON | TON
"order_ref": "order_2026_06_02_0001", // unique, idempotent
"expires_in": 900, // optional, 60..3600
"amount_usdt": "100" // hoặc amount_fiat + fiat_currency (USD|VND|EUR)
}
Response 201 (lần đầu) / 200 (gọi lại cùng order_ref)
{ "success": true, "data": {
"order_id": "PAY6a1dd...", "pay_address": "0x...B5EE",
"pay_link": "https://vinxpay.site/pay/PAY6a1dd...",
"amount_usdt": "100.001940", // SỐ CÓ TAG — khách gửi đúng số này
"amount_fiat": "2500000", "fiat_currency": "VND", "rate_at_create": "26287",
"network": "BEP20", "expires_at": "2026-06-02T03:15:48.301Z", "status": "PENDING"
}}
amount_usdt là tag nhận diện đơn (2 đơn cùng 100 USDT không phân biệt được on-chain). Gửi sai số → UNDERPAID/OVERPAID (orphan, xử lý tay).Biến thể định giá theo VND
Headers bắt buộc (signed)
X-Api-Key: vinx_...
X-Timestamp: <unix giây>
X-Nonce: <uuid duy nhất>
X-Signature: hex(HMAC-SHA256(secret, "ts.POST.path.rawBody"))
Idempotency-Key: <chuỗi tuỳ ý>
Content-Type: application/json// body
{ "merchant_order_id": "INV-1", "amount_vnd": 260000, "network": "TRC20",
"customer_id": "u1", "webhook_url": "https://..." }
// -> { payment_id, network, wallet_address, amount_usdt, amount_vnd,
// payment_url, expires_at }
Trạng thái đơn nạp
PENDING → DETECTED → CONFIRMING → SUCCESS, hoặc UNDERPAID/OVERPAID/EXPIRED/FAILED. Trạng thái cuối là SUCCESS (không có "PAID"). Mỗi bước fire 1 webhook payment.* (xem mục Webhook).
Các trường hợp cash-in
| Tình huống | Kết quả |
|---|---|
| Gửi đúng số có tag | SUCCESS + webhook payment.success |
| Gửi thiếu tiền | UNDERPAID — merchant xử lý trong dashboard, BE không tự cộng |
| Gửi dư tiền | OVERPAID — phần dư xử lý trong dashboard |
| Hết hạn trước khi khách gửi | EXPIRED; thao tác lên đơn hết hạn → PAYMENT_EXPIRED (410) |
Gọi lại cùng order_ref | Trả lại đơn cũ (idempotent), không tạo đơn mới |
| Gửi sai địa chỉ/số/gửi trễ bất thường | BE xử lý nội bộ (khoản lạ) — merchant không cần gọi API gì thêm |
Chi tiết đơn nạp
Headers bắt buộc (signed)
X-Api-Key: vinx_...
X-Timestamp: <unix giây>
X-Nonce: <uuid duy nhất>
X-Signature: hex(HMAC-SHA256(secret, "ts.GET.path.")) # rawBody rỗngTrả cùng shape cash-in + status hiện tại + tx_hash nếu có deposit. Trang checkout poll qua public: GET /api/public/payments/:id (không cần auth, kèm qr).
| Code | HTTP | Khi nào |
|---|---|---|
VALIDATION_ERROR | 422 | Thiếu/sai field |
NOT_FOUND | 404 | Chưa có ví active cho network |
CASH_IN_QUOTA_EXCEEDED | 402 | Hết hạn mức nạp |
Tạo đơn rút — cash-out
Headers bắt buộc (signed)
X-Api-Key: vinx_...
X-Timestamp: <unix giây>
X-Nonce: <uuid duy nhất>
X-Signature: hex(HMAC-SHA256(secret, "ts.POST.path.rawBody"))
Idempotency-Key: <chuỗi tuỳ ý>
Content-Type: application/jsonRút USDT về địa chỉ ngoài. Cần số dư USDT thật + gas. PIN bắt buộc.
{
"request_id": "withdraw_2026_06_02_0001", // idempotent
"network": "BEP20", "to_address": "0xRecipient...",
"pin": "274619", "amount_usdt": "10" // hoặc amount_fiat + fiat_currency
}
Response 202 / 200 (replay)
{ "success": true, "data": {
"payout_id": "PO6a1dd...", "status": "QUEUED",
"amount_usdt": "10.00", "network": "BEP20", "to_address": "0xRecipient..."
}}
Cách 2 bước: quote → payout (kiểm tra gas trước)
Headers bắt buộc (signed)
X-Api-Key: vinx_...
X-Timestamp: <unix giây>
X-Nonce: <uuid duy nhất>
X-Signature: hex(HMAC-SHA256(secret, "ts.POST.path.rawBody"))
Idempotency-Key: <chuỗi tuỳ ý>
Content-Type: application/json// body
{ "network": "TRC20", "amount_usdt": "5", "to_address": "T..." }
// -> { network, amount_usdt, gas_asset, estimated_gas, merchant_gas_balance,
// can_payout, quote_id, expires_in }
Headers bắt buộc (signed)
X-Api-Key: vinx_...
X-Timestamp: <unix giây>
X-Nonce: <uuid duy nhất>
X-Signature: hex(HMAC-SHA256(secret, "ts.POST.path.rawBody"))
Idempotency-Key: <chuỗi tuỳ ý>
Content-Type: application/json// body
{ "merchant_order_id": "WD-1", "quote_id": "...", "network": "TRC20",
"amount_usdt": "5", "to_address": "T..." }
// -> { status, payout } // status thường LOCKED; có thể 202 MANUAL_REVIEW_REQUIRED / 403 RISK_REJECTED
/api/v1/payouts lại với quote_id đã dùng → 410 QUOTE_EXPIRED. Quote TTL ~60s.Trạng thái lệnh rút
PENDING → LOCKED → SIGNING → BROADCAST → CONFIRMING → SUCCESS, hoặc FAILED/CANCELLED. Status trả về luôn là QUEUED (cách 1 phát). Mỗi bước fire 1 webhook payout.* (payout.pending chỉ khi cần duyệt tay).
Chi tiết lệnh rút
Headers bắt buộc (signed)
X-Api-Key: vinx_...
X-Timestamp: <unix giây>
X-Nonce: <uuid duy nhất>
X-Signature: hex(HMAC-SHA256(secret, "ts.GET.path.")) # rawBody rỗng| Code | HTTP | Khi nào |
|---|---|---|
INVALID_PIN | 401 | Sai PIN (PIN sai 5 lần → TEMP_LOCKED 5 phút) |
INVALID_ADDRESS | 400 | Địa chỉ không hợp lệ với mạng |
ADDRESS_NOT_WHITELISTED | 403 | Địa chỉ rút không trong whitelist |
INSUFFICIENT_BALANCE / INSUFFICIENT_GAS_BALANCE | 402 | Không đủ USDT / gas (details.topup_address) |
QUOTE_EXPIRED / QUOTE_NOT_FOUND | 410 / 404 | Quote đã dùng/hết hạn / không tồn tại |
RISK_REJECTED | 403 | Risk engine từ chối (details.reasons) |
MANUAL_REVIEW_REQUIRED | 202 | Cần admin duyệt tay (trả payout_id PENDING) |
PIN_NOT_SET | 422 | Chưa set PIN (set trong dashboard) |
TEMP_LOCKED | 423 | PIN sai 5 lần → tạm khoá 5 phút (details.lockedUntil) |
CASH_OUT_QUOTA_EXCEEDED | 402 | Vượt hạn mức rút của gói |
NO_ACTIVE_PACKAGE | 402 | Chưa có gói dịch vụ active |
Gọi lại cùng request_id / merchant_order_id → trả đơn cũ (idempotent), không tạo lệnh mới.
Webhook nhận sự kiện
Khi một đơn nạp/rút trên VINX kết thúc (thành công hoặc thất bại), VINX tự động gửi một HTTP POST tới máy chủ của bạn để báo tin. Bạn không cần liên tục hỏi "đơn xong chưa?" — VINX chủ động gọi tới ngay khi có kết quả.
GET /api/v1/payments/:id. (Socket.IO là kênh nội bộ dashboard, KHÔNG dành cho tích hợp.)0. Hiểu trong 60 giây
- Bạn tạo đơn nạp
POST /api/v1/cash-in→ nhậnpay_address+amount_usdt. - Khách chuyển USDT đúng số tiền đến địa chỉ đó.
- VINX phát hiện giao dịch on-chain, chờ đủ confirm.
- VINX gửi webhook POST tới URL bạn đăng ký ở mỗi bước trạng thái (kèm chữ ký để xác minh) — bước cuối là
payment.success. - Bạn nhận, ghi sổ, trả
200 OK. Xong.
1. VINX bắn webhook ở những thời điểm nào?
Kênh chính bắn webhook ở MỖI bước chuyển trạng thái (mỗi status 1 event). Lọc theo header X-Vinx-Event hoặc field status trong payload. Tương thích ngược: nếu chỉ nghe payment.success / payout.success / payout.failed, các event mới có tên khác nên bỏ qua được.
| Event | Loại | Mô tả |
|---|---|---|
payment.pending | Cash-in | Đơn nạp vừa tạo, chờ khách chuyển. |
payment.detected | Cash-in | Đã thấy giao dịch on-chain, chờ confirm. |
payment.confirming | Cash-in | Đang xác nhận đủ block trên mạng. |
payment.success | Cash-in | Đủ USDT + đủ confirm → số dư được cộng. |
payment.expired | Cash-in | Đơn nạp PENDING hết hạn (thay cho payment.failed cũ). |
payout.pending | Cash-out | Chỉ khi lệnh rút cần duyệt tay (MANUAL_REVIEW). |
payout.locked → signing → broadcast → confirming | Cash-out | Các bước xử lý lệnh rút. |
payout.success | Cash-out | Đã broadcast + xác nhận thành công. |
payout.failed | Cash-out | Lệnh rút thất bại / bị huỷ (kèm failure_code). |
Mỗi webhook kèm header X-Vinx-Webhook-Channel: main (kênh chính — nạp/rút) hoặc external (kênh đơn ngoài, bảng dưới).
Kênh đơn ngoài (external) — đăng ký URL + secret riêng trong dashboard
Báo các đơn KHÔNG do bạn tạo qua API (nạp lạ vào ví, USDT rời ví ngoài hệ thống). Ký/verify giống kênh chính nhưng dùng secret riêng; header X-Vinx-Webhook-Channel: external. Payload chi tiết ở mục 6b.
| Event | Loại | Mô tả |
|---|---|---|
external_deposit.detected | Số dư lạ | Phát hiện nạp lạ vào ví (chưa khớp đơn nào). |
external_deposit.assigned | Số dư lạ | Khoản lạ được gán vào 1 đơn. |
external_deposit.accepted | Số dư lạ | Khoản lạ được cộng vào số dư. |
external_deposit.refunded | Số dư lạ | Khoản lạ được hoàn về địa chỉ gốc. |
wallet.external_outflow | Rút ngoài | USDT rời ví do bạn tự ký (đã bật cho phép). |
wallet.unknown_outflow | CẢNH BÁO | Rút ngoài KHÔNG khai báo → ví bị tạm khoá. |
2. Đăng ký URL nhận webhook (Dashboard)
| Method | Path | Mô tả |
|---|---|---|
| PUT | /api/merchant/webhooks | URL chung mọi mạng — { webhookUrl } |
| PUT | /api/merchant/webhooks/:network | URL theo từng mạng — { url } (ưu tiên hơn URL chung) |
URL bắt buộc https:// + chứng chỉ TLS hợp lệ (không nhận plain HTTP / self-signed).
3. Xác minh URL bằng challenge nonce
Ngay khi lưu URL, VINX gửi 1 cuộc gọi thử kèm chuỗi ngẫu nhiên (nonce). Máy chủ bạn phải echo lại đúng nonce trong 10 giây thì URL mới được lưu.
// VINX gửi đến URL bạn vừa lưu:
POST <your-webhook-url>
X-Vinx-Event: webhook.verify
{ "event": "webhook.verify", "nonce": "8b3c91f2…(hex 32)" }
// Máy chủ bạn TRẢ trong 10s — chọn 1 trong 2:
// Cách 1 — body JSON: { "nonce": "8b3c91f2…" }
// Cách 2 — header: X-Vinx-Challenge-Nonce: 8b3c91f2…
Pass → URL lưu, trả về webhookSecret plaintext chỉ 1 lần (lưu kỹ ngay), webhookUrlVerified=true.
Fail → 422 WEBHOOK_URL_VERIFY_FAILED kèm details.reason (NONCE_MISMATCH/TIMEOUT/NON_2XX) + details.httpStatus.
{ skipVerify: true } trong PUT) — rủi ro thuộc về bạn.4. Headers mỗi delivery
Content-Type: application/json
X-Vinx-Event: payment.success
X-Vinx-Network: BEP20
X-Vinx-Webhook-Channel: main # main = kênh chính | external = kênh đơn ngoài
X-Vinx-Timestamp: 1717258952 # unix GIÂY
X-Vinx-Delivery-Id: d_8b3c91f2_5a4d
X-Vinx-Webhook-Version: v2
X-Vinx-Signature: hex(HMAC_SHA256(secret, rawBody))
X-Vinx-Signature-V2: hex(HMAC_SHA256(secret, `${ts}.${deliveryId}.${rawBody}`))
X-Vinx-Signature-Previous: hex(HMAC_SHA256(prevSecret, rawBody)) # chỉ trong 24h grace sau rotate
| Header | Mô tả |
|---|---|
X-Vinx-Timestamp | unix GIÂY. Lệch quá ±300s → từ chối (chống phát lại) |
X-Vinx-Delivery-Id | Mã duy nhất mỗi lần gửi (retry cùng event vẫn dùng cùng id) → dedupe |
X-Vinx-Signature (V1) | HMAC-SHA256(secret, rawBody) — cho merchant cũ |
X-Vinx-Signature-V2 | HMAC-SHA256(secret, ts.deliveryId.rawBody) — khuyến nghị verify cái này (chống replay) |
X-Vinx-Signature-Previous | Ký bằng secret CŨ — chỉ có trong 24h sau khi rotate |
5. Mẫu payload kênh chính (X-Vinx-Webhook-Channel: main)
payment.pending/detected/confirming, payout.locked/signing/broadcast/confirming) dùng cùng shape như event success của loại đó, chỉ khác field status + tên event (tx_hash có thể null cho tới khi on-chain). payment.expired: như payment.failed với status:"EXPIRED".5.1 payment.success — nạp thành công
{ "event":"payment.success", "payment_id":"PAY6a1dd2c63afb7b7eb9decc87",
"merchant_order_id":"order_ref bạn gửi lúc tạo", "network":"BEP20", "asset":"USDT",
"amount":"5.003298", "amount_usdt_received":"5.003298",
"amount_fiat":"100000", "fiat_currency":"VND",
"rate_at_create":"26287", "amount_fiat_at_success":"131500",
"tx_hash":"0xabc...", "status":"SUCCESS" }
| Field | Mô tả |
|---|---|
payment_id | Mã đơn nạp VINX cấp — dùng để dedupe |
merchant_order_id | Đúng order_ref bạn gửi lúc /cash-in — tra ngược đơn hàng của bạn |
amount | Số USDT yêu cầu ban đầu (đã chèn 4 chữ số tag) |
amount_usdt_received | Số USDT thực nhận on-chain (= amount hoặc nhỉnh hơn nếu OVERPAID) |
amount_fiat | Số fiat lúc tạo đơn (mode B); null nếu định giá USDT trực tiếp (mode A) |
rate_at_create | 1 USDT = ? fiat lúc tạo đơn — cố định trong sổ kế toán |
amount_fiat_at_success | Số fiat quy đổi theo rate live lúc thanh toán thành công |
tx_hash | Hash giao dịch on-chain (tra trên explorer) |
5.2 payment.failed — nạp thất bại
{ "event":"payment.failed", "payment_id":"PAY...", "merchant_order_id":"order_ref",
"network":"BEP20", "asset":"USDT", "amount":"5.00", "tx_hash":null, "status":"FAILED" }
Bắn khi đơn EXPIRED (hết expires_at không có deposit) hoặc bị CSKH huỷ. Hệ thống bạn nên đánh dấu đơn "huỷ" và (nếu giữ hàng) trả hàng lại.
5.3 payout.success — rút thành công
{ "event":"payout.success", "payout_id":"PO6a1dd...", "merchant_order_id":"request_id bạn gửi",
"network":"BEP20", "asset":"USDT", "amount":"10.00",
"gas_asset":"BNB", "gas_used":"0.000412",
"to_address":"0xRecipient...", "tx_hash":"0xdef...", "status":"SUCCESS" }
gas_used là phí mạng VINX đã trả thay bạn — trừ từ số dư gas, KHÔNG trừ vào USDT.
5.4 payout.failed — rút thất bại
{ "event":"payout.failed", "payout_id":"PO...", "merchant_order_id":"request_id",
"network":"BEP20", "asset":"USDT", "amount":"10.00", "gas_asset":"BNB",
"to_address":"0xRecipient...", "tx_hash":null,
"failure_code":"...", "failure_reason":"...", "status":"FAILED" }
Lý do thường gặp: địa chỉ sai (tx revert), risk engine từ chối, không đủ gas lúc broadcast. Số dư USDT đã khoá được mở lại tự động — bạn không mất tiền.
6b. Webhook đơn ngoài (kênh external)
X-Vinx-Webhook-Channel: external. Ký/verify giống kênh chính nhưng dùng external secret; cũng retry 6 lần. Báo các đơn KHÔNG do merchant tạo qua API.Nhóm 1 — Nạp lạ vào ví (external_deposit.*)
USDT vào ví merchant nhưng không khớp invoice nào:
external_deposit.detected → external_deposit.assigned | external_deposit.accepted | external_deposit.refunded
{ "event":"external_deposit.detected", "delivery_id":"del_...", "sent_at":"...",
"data": { "network":"TRC20", "asset":"USDT", "address":"T...(ví merchant)",
"tx_hash":"...", "from_address":"T...", "amount":"50.00",
"status":"DETECTED", "block_number":..., "observed_at":"..." } }
Nhóm 2 — USDT rời ví không do hệ thống gửi
wallet.external_outflow — bạn ĐÃ bật "Cho phép rút ngoài" → chỉ ghi nhận (benign), ví không khoá.
{ "event":"wallet.external_outflow", "delivery_id":"del_...", "sent_at":"...",
"data": { "network":"BEP20", "asset":"USDT", "address":"0xMerchantWallet...",
"tx_hash":"0x...", "to_address":"0xMerchantOtherWallet...", "amount":"250.00",
"classification":"EXTERNAL_OUTFLOW", "block_number":..., "observed_at":"..." } }
wallet.unknown_outflow — CHƯA bật "Cho phép rút ngoài". CẢNH BÁO nghi rò khoá: hệ thống tự tạm khoá ví, mọi cash-out trả 403 FORBIDDEN tới khi CSKH mở.
{ "event":"wallet.unknown_outflow", "delivery_id":"del_...", "sent_at":"...",
"data": { "network":"BEP20", "asset":"USDT", "address":"0xMerchantWallet...",
"tx_hash":"0x...", "to_address":"0xUnknownDestination...", "amount":"250.00",
"classification":"UNKNOWN_OUTFLOW", "block_number":..., "observed_at":"..." } }
Khi nhận unknown_outflow: (1) alert team an ninh ngay; (2) kiểm tra quyền truy cập key; (3) nếu rút hợp lệ → bật "Cho phép rút ngoài" rồi liên hệ CSKH mở khoá; (4) nếu nghi key lộ → rotate ví + liên hệ CSKH giữ ví khoá.
data. Đăng ký URL/secret kênh external ở Dashboard (không có API tích hợp để cấu hình).6. Verify chữ ký — code mẫu (Node/Express)
express.raw). (2) so sánh bằng === → SAI, dùng crypto.timingSafeEqual.import crypto from "crypto";
import express from "express";
const app = express();
const SECRET = process.env.VINX_WEBHOOK_SECRET;
const SECRET_PREVIOUS = process.env.VINX_WEBHOOK_SECRET_PREVIOUS; // nếu vừa rotate trong 24h
const seen = new Set(); // production: Redis SET TTL 7 ngày
function verifySig(secret, payload, sig){
if(!sig || !secret) return false;
const exp = crypto.createHmac("sha256", secret).update(payload).digest("hex");
if(sig.length !== exp.length) return false;
return crypto.timingSafeEqual(Buffer.from(sig,"hex"), Buffer.from(exp,"hex"));
}
// QUAN TRỌNG: express.raw, KHÔNG express.json
app.post("/your-webhook", express.raw({ type:"application/json" }), (req, res) => {
const ts = req.get("X-Vinx-Timestamp"), deliveryId = req.get("X-Vinx-Delivery-Id");
const sigV2 = req.get("X-Vinx-Signature-V2"), sigV1 = req.get("X-Vinx-Signature");
const sigPrev = req.get("X-Vinx-Signature-Previous");
const raw = req.body; // Buffer
// 1) Anti-replay: timestamp lệch quá ±300s → từ chối
const skew = Math.abs(Date.now()/1000 - Number(ts));
if(!Number.isFinite(skew) || skew > 300) return res.status(401).end();
// 2) Verify ưu tiên V2 → V1 → Previous (grace 24h)
const okV2 = sigV2 && verifySig(SECRET, `${ts}.${deliveryId}.${raw}`, sigV2);
const okV1 = !okV2 && sigV1 && verifySig(SECRET, raw, sigV1);
const okPrev = !okV2 && !okV1 && sigPrev && SECRET_PREVIOUS && verifySig(SECRET_PREVIOUS, raw, sigPrev);
if(!okV2 && !okV1 && !okPrev) return res.status(401).end();
// 3) Parse SAU khi verify
const event = JSON.parse(raw.toString());
// 4) Dedupe theo deliveryId (hoặc payment_id/payout_id)
const key = deliveryId || event.payment_id || event.payout_id;
if(seen.has(key)) return ackOk(res, deliveryId);
seen.add(key);
// 5) Ghi DB / cộng số dư XONG rồi mới trả 200
return ackOk(res, deliveryId);
});
// Mutual ACK: ký deliveryId bằng webhookSecret → VINX biết URL còn nắm đúng secret
function ackOk(res, deliveryId){
res.setHeader("X-Merchant-Ack", crypto.createHmac("sha256", SECRET).update(deliveryId).digest("hex"));
return res.status(200).json({ received: true });
}
7. Máy chủ bạn phải trả gì?
VINX chỉ xét HTTP status, không kiểm body.
| Trả về | Kết quả |
|---|---|
| 2xx (200/201/202/204) | PASS — không retry |
| 4xx / 5xx | FAIL → retry |
| Timeout > 10s / connection refused / DNS fail | FAIL → retry |
Nên trả 200 { "received": true } kèm header X-Merchant-Ack = HMAC của deliveryId (tuỳ chọn — thiếu thì delivery vẫn pass nhưng bị flag ackTrusted=false).
8. Retry policy
Lần 1: ngay khi sự kiện xảy ra → timeout
Lần 2: +5 giây → timeout
Lần 3: +10 giây (tổng +15s) → timeout
Lần 4: +20 giây (tổng +35s) → bạn trả 500
Lần 5: +40 giây (tổng +75s) → bạn trả 200 ✓ DELIVERED (dừng retry)
Lần 6 (nếu lần 5 vẫn fail): +80 giây (tổng +155s)
Lần 7 (nếu lần 6 vẫn fail): +160 giây (tổng +315s)
→ Sau 6 lần fail liên tiếp: WebhookLog.status = "failed"
Tối đa 6 lần thử, kéo dài ~5 phút, mỗi lần timeout 10s. Sau đó liên hệ CSKH để gửi lại tay.
9. Idempotency (bắt buộc)
VINX có thể gửi cùng 1 event nhiều lần (retry / gửi lại tay). Máy chủ bạn phải dedupe theo X-Vinx-Delivery-Id (hoặc payment_id/payout_id), lưu ≥ 7 ngày (Redis SET / DB) — không cộng tiền / gửi mail 2 lần.
10. Đổi (rotate) secret — 24h grace, không downtime
POST /api/merchant/webhooks/rotate-secret # URL chung
POST /api/merchant/webhooks/:network/rotate-secret # theo mạng
// → { webhookSecret: "<mới, 1 lần>", previousValidUntil: "ISO +24h" }
Trong 24h: VINX ký bằng secret mới (X-Vinx-Signature/-V2) và đính kèm X-Vinx-Signature-Previous ký bằng secret cũ. Bạn chấp nhận nếu 1 trong 2 khớp → có 24h deploy secret mới ra mọi instance. Sau 24h secret cũ bị huỷ.
11. Test trước go-live
Dashboard /webhooks → mỗi mạng có nút Kiểm tra: VINX bắn 1 event mẫu (payment.success) kèm đủ header V1/V2/Timestamp/Delivery-Id. (API: POST /api/merchant/webhooks/:network/test.)
12. Checklist bắt buộc
- URL
https://+ TLS hợp lệ; echo đúng nonce khi đăng ký. - Verify V2 trước, fallback V1 & Previous; dùng
timingSafeEqualtrên raw body. - Kiểm
X-Vinx-Timestamplệch ≤ ±300s. - Dedupe theo Delivery-Id / payment_id / payout_id, TTL ≥ 7 ngày.
- Ghi DB xong mới trả 2xx (< 8s), việc nặng đẩy queue; lưu raw body + signature ≥ 7 ngày.
13. Câu hỏi thường gặp
| Câu hỏi | Trả lời |
|---|---|
| Tại sao không nhận được webhook? | Kiểm: (a) URL còn sống, không 5xx; (b) cert TLS hợp lệ, chưa hết hạn; (c) firewall không chặn IP outbound của VINX; (d) xem log gần nhất ở /webhooks. |
| Verify chữ ký luôn sai dù secret đúng? | 99% do parse JSON trước rồi mới verify. Dùng express.raw thay vì express.json — chữ ký tính trên raw bytes nguyên bản. |
| Có thể tắt webhook không? | Có — xoá URL ở /webhooks. VINX ngừng gửi (đơn vẫn xử lý bình thường, chỉ là bạn không nhận thông báo realtime). |
| Có nhận được trạng thái trung gian không? | Có — kênh chính giờ bắn webhook ở mỗi status (payment.pending/detected/confirming…, payout.locked/signing/broadcast/confirming…). Lọc theo X-Vinx-Event hoặc field status; nếu chỉ cần kết quả cuối thì bỏ qua các event trung gian. (Socket.IO là kênh nội bộ dashboard, không dành cho tích hợp.) |
X-Merchant-Ack có bắt buộc? | Không — thiếu vẫn tính pass. Nhưng nếu thiếu/sai, VINX ghi nhận để CSKH cảnh báo (URL có thể bị chiếm). |
| Nhận webhook 2 lần cùng payment_id? | Bình thường — retry (vì lần trước trả 500/timeout) hoặc CSKH gửi lại tay. Dedupe phía bạn (mục 9) sẽ ngăn xử lý 2 lần. |
| Còn thắc mắc? | Liên hệ CSKH qua dashboard, cung cấp X-Vinx-Delivery-Id để tra nhật ký gửi tin. |
Bộ công cụ test
Cổng test trực tuyến: test.vinxpay.site — đăng nhập merchant → test mọi endpoint /api/v1, xem chữ ký HMAC, response (raw + format), cấu hình/nhận webhook, tạo chữ ký.
File tải về
Đang tải danh sách…
Mã lỗi
Luôn kiểm tra theo code; message có thể thay đổi.
Chỉ liệt kê mã mà API tích hợp (/api/v1 + webhook) trả về. (Mã đăng nhập/2FA/tài khoản thuộc dashboard, không xuất hiện ở đây.)
| Code | HTTP | Ý nghĩa |
|---|---|---|
INVALID_API_KEY | 401 | Thiếu/sai X-Api-Key |
INVALID_SIGNATURE | 401 | Sai chữ ký |
REPLAY_DETECTED | 401 | Timestamp lệch / nonce trùng / thiếu nonce |
IP_NOT_WHITELISTED | 403 | IP ngoài whitelist của API key |
MISSING_IDEMPOTENCY_KEY | 400 | POST thiếu Idempotency-Key |
IDEMPOTENCY_CONFLICT | 409 | Idempotency-Key dùng lại với body khác |
RATE_LIMITED | 429 | Quá nhiều request (giới hạn theo API key, kèm Retry-After) |
VALIDATION_ERROR | 422 | Body/param sai (kèm details.field) |
DUPLICATE_ORDER | 409 | Trùng order_ref / request_id |
NOT_FOUND | 404 | Không tìm thấy đơn |
PAYMENT_EXPIRED | 410 | Đơn nạp hết hạn |
INSUFFICIENT_BALANCE | 402 | Số dư USDT không đủ |
INSUFFICIENT_GAS_BALANCE | 402 | Gas không đủ (kèm thông tin nạp gas) |
QUOTE_NOT_FOUND | 404 | Quote không tồn tại / không thuộc bạn |
QUOTE_EXPIRED | 410 | Quote hết hạn HOẶC đã dùng (dùng 1 lần) |
INVALID_ADDRESS | 400/422 | Địa chỉ rút sai định dạng |
ADDRESS_NOT_WHITELISTED | 403 | Địa chỉ rút ngoài whitelist |
RISK_REJECTED | 403 | Bị từ chối bởi kiểm soát rủi ro |
MANUAL_REVIEW_REQUIRED | 202 | Lệnh rút cần duyệt tay (trả payout_id PENDING) |
PIN_NOT_SET | 422 | Chưa set PIN (set trong dashboard) |
INVALID_PIN | 401 | PIN sai (details.attempts) |
TEMP_LOCKED | 423 | PIN sai 5 lần → tạm khoá 5 phút (details.lockedUntil) |
CASH_IN_QUOTA_EXCEEDED | 402 | Vượt hạn mức nạp của gói |
CASH_OUT_QUOTA_EXCEEDED | 402 | Vượt hạn mức rút của gói |
NO_ACTIVE_PACKAGE | 402 | Chưa có gói dịch vụ active |
RPC_UNAVAILABLE | 502 | Mạng blockchain tạm lỗi (thử lại) |
MAINTENANCE | 503 | Hệ thống đang bảo trì (kèm Retry-After) |
INTERNAL | 500 | Lỗi hệ thống |