VINXPAYAPI Docs Cổng test API ↗

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

  1. Admin duyệt tài khoản → nhận keyId + secret (secret chỉ hiện 1 lần).
  2. Đặt PIN 6 số ở Bảo mật, cấu hình URL webhook.
  3. GET /api/v1/balance để xác nhận ký HMAC hoạt động.
  4. POST /api/v1/cash-in → nhận pay_address → khách chuyển USDT → lắng nghe payment.success.
  5. Khi rút: POST /api/v1/cash-out kèm pin → lắng nghe payout.success.

Envelope chung:

// OK:  { "success": true, "data": { ... } }
// Lỗi: { "success": false, "code": "...", "message": "...", "details": {} }
Kiểm tra theo 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.

HeaderMô tả
X-Api-KeykeyId (vinx_<32hex>)
X-TimestampUnix giây, sai số ±300s so với server
X-NonceChuỗi duy nhất mỗi request (uuid v4), nhớ 600s
X-Signaturehex(HMAC-SHA256(secret, "${ts}.${METHOD}.${path}.${rawBody}"))
Idempotency-Key(POST) trùng key → trả kết quả cũ
  • METHOD viết HOA; path gồm cả /api/v1 và 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 });
}
Cần tạo chữ ký thử? Mở Cổng test API → tab Ký HMAC (tính trên trình duyệt, có sẵn lệnh curl).
💰

Lấy số dư

GET /api/v1/balance
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

Số 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 *_fiat per-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. secret chỉ 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.
Endpoint quản lý key (/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

  1. Merchant server gọi POST /api/v1/cash-in (network, amount_usdt hoặc amount_fiat+fiat_currency, order_ref, expires_in).
  2. BE trả 201: order_id, pay_address, pay_link, amount_usdt CÓ TAG (vd 10.004873), expires_at, status PENDING.
  3. 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.
  4. BE phát hiện on-chain: PENDING → DETECTED → CONFIRMING → SUCCESS.
  5. 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 poll GET /api/public/payments/:id để cập nhật UI.
POST /api/v1/cash-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

Tạ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"
}}
Số có tag: 4 chữ số cuối của 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

POST /api/v1/payments
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

PENDINGDETECTEDCONFIRMINGSUCCESS, 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ốngKết quả
Gửi đúng số có tagSUCCESS + webhook payment.success
Gửi thiếu tiềnUNDERPAID — merchant xử lý trong dashboard, BE không tự cộng
Gửi tiềnOVERPAID — phần dư xử lý trong dashboard
Hết hạn trước khi khách gửiEXPIRED; thao tác lên đơn hết hạn → PAYMENT_EXPIRED (410)
Gọi lại cùng order_refTrả lại đơn cũ (idempotent), không tạo đơn mới
Gửi sai địa chỉ/số/gửi trễ bất thườngBE 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

GET /api/v1/payments/:id
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

Trả 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).

CodeHTTPKhi nào
VALIDATION_ERROR422Thiếu/sai field
NOT_FOUND404Chưa có ví active cho network
CASH_IN_QUOTA_EXCEEDED402Hết hạn mức nạp

Tạo đơn rút — cash-out

POST /api/v1/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/json

Rú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)

POST /api/v1/payouts/quote
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 }
POST /api/v1/payouts
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
Quote dùng 1 lần. Gọi /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

PENDINGLOCKED → SIGNING → BROADCAST → CONFIRMINGSUCCESS, 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

GET /api/v1/payouts/:id
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
CodeHTTPKhi nào
INVALID_PIN401Sai PIN (PIN sai 5 lần → TEMP_LOCKED 5 phút)
INVALID_ADDRESS400Địa chỉ không hợp lệ với mạng
ADDRESS_NOT_WHITELISTED403Địa chỉ rút không trong whitelist
INSUFFICIENT_BALANCE / INSUFFICIENT_GAS_BALANCE402Không đủ USDT / gas (details.topup_address)
QUOTE_EXPIRED / QUOTE_NOT_FOUND410 / 404Quote đã dùng/hết hạn / không tồn tại
RISK_REJECTED403Risk engine từ chối (details.reasons)
MANUAL_REVIEW_REQUIRED202Cần admin duyệt tay (trả payout_id PENDING)
PIN_NOT_SET422Chưa set PIN (set trong dashboard)
TEMP_LOCKED423PIN sai 5 lần → tạm khoá 5 phút (details.lockedUntil)
CASH_OUT_QUOTA_EXCEEDED402Vượt hạn mức rút của gói
NO_ACTIVE_PACKAGE402Chư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ả.

Đây là kênh chính thức để cập nhật trạng thái đơn. Ưu tiên webhook thay vì poll lặp 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

  1. Bạn tạo đơn nạp POST /api/v1/cash-in → nhận pay_address + amount_usdt.
  2. Khách chuyển USDT đúng số tiền đến địa chỉ đó.
  3. VINX phát hiện giao dịch on-chain, chờ đủ confirm.
  4. 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.
  5. 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.

EventLoạiMô tả
payment.pendingCash-inĐơn nạp vừa tạo, chờ khách chuyển.
payment.detectedCash-inĐã thấy giao dịch on-chain, chờ confirm.
payment.confirmingCash-inĐang xác nhận đủ block trên mạng.
payment.successCash-inĐủ USDT + đủ confirm → số dư được cộng.
payment.expiredCash-inĐơn nạp PENDING hết hạn (thay cho payment.failed cũ).
payout.pendingCash-outChỉ khi lệnh rút cần duyệt tay (MANUAL_REVIEW).
payout.locked → signing → broadcast → confirmingCash-outCác bước xử lý lệnh rút.
payout.successCash-outĐã broadcast + xác nhận thành công.
payout.failedCash-outLệ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.

EventLoạiMô tả
external_deposit.detectedSố dư lạPhát hiện nạp lạ vào ví (chưa khớp đơn nào).
external_deposit.assignedSố dư lạKhoản lạ được gán vào 1 đơn.
external_deposit.acceptedSố dư lạKhoản lạ được cộng vào số dư.
external_deposit.refundedSố dư lạKhoản lạ được hoàn về địa chỉ gốc.
wallet.external_outflowRút ngoàiUSDT rời ví do bạn tự ký (đã bật cho phép).
wallet.unknown_outflowCẢNH BÁORút ngoài KHÔNG khai báo → ví bị tạm khoá.

2. Đăng ký URL nhận webhook (Dashboard)

MethodPathMô tả
PUT/api/merchant/webhooksURL chung mọi mạng — { webhookUrl }
PUT/api/merchant/webhooks/:networkURL 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.
Fail422 WEBHOOK_URL_VERIFY_FAILED kèm details.reason (NONCE_MISMATCH/TIMEOUT/NON_2XX) + details.httpStatus.

Legacy không echo được nonce? Bật Bỏ qua xác minh URL trên dashboard (gửi { 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
HeaderMô tả
X-Vinx-Timestampunix GIÂY. Lệch quá ±300s → từ chối (chống phát lại)
X-Vinx-Delivery-IdMã 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-V2HMAC-SHA256(secret, ts.deliveryId.rawBody) — khuyến nghị verify cái này (chống replay)
X-Vinx-Signature-PreviousKý bằng secret CŨ — chỉ có trong 24h sau khi rotate

5. Mẫu payload kênh chính (X-Vinx-Webhook-Channel: main)

Các event trung gian (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" }
FieldMô tả
payment_idMã đơ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
amountSố USDT yêu cầu ban đầu (đã chèn 4 chữ số tag)
amount_usdt_receivedSố USDT thực nhận on-chain (= amount hoặc nhỉnh hơn nếu OVERPAID)
amount_fiatSố fiat lúc tạo đơn (mode B); null nếu định giá USDT trực tiếp (mode A)
rate_at_create1 USDT = ? fiat lúc tạo đơn — cố định trong sổ kế toán
amount_fiat_at_successSố fiat quy đổi theo rate live lúc thanh toán thành công
tx_hashHash 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)

Kênh riêng: URL + secret riêng, đăng ký trong Dashboard (mục webhook đơn ngoài). Mỗi delivery kèm header 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.detectedexternal_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á.

Các event này dùng snake_case trong 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)

2 sai lầm hay gặp: (1) parse JSON trước rồi mới verify → SAI, phải verify trên raw body (dùng 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 / 5xxFAIL → retry
Timeout > 10s / connection refused / DNS failFAIL → 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 timingSafeEqual trên raw body.
  • Kiểm X-Vinx-Timestamp lệ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ỏiTrả 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.)

CodeHTTPÝ nghĩa
INVALID_API_KEY401Thiếu/sai X-Api-Key
INVALID_SIGNATURE401Sai chữ ký
REPLAY_DETECTED401Timestamp lệch / nonce trùng / thiếu nonce
IP_NOT_WHITELISTED403IP ngoài whitelist của API key
MISSING_IDEMPOTENCY_KEY400POST thiếu Idempotency-Key
IDEMPOTENCY_CONFLICT409Idempotency-Key dùng lại với body khác
RATE_LIMITED429Quá nhiều request (giới hạn theo API key, kèm Retry-After)
VALIDATION_ERROR422Body/param sai (kèm details.field)
DUPLICATE_ORDER409Trùng order_ref / request_id
NOT_FOUND404Không tìm thấy đơn
PAYMENT_EXPIRED410Đơn nạp hết hạn
INSUFFICIENT_BALANCE402Số dư USDT không đủ
INSUFFICIENT_GAS_BALANCE402Gas không đủ (kèm thông tin nạp gas)
QUOTE_NOT_FOUND404Quote không tồn tại / không thuộc bạn
QUOTE_EXPIRED410Quote hết hạn HOẶC đã dùng (dùng 1 lần)
INVALID_ADDRESS400/422Địa chỉ rút sai định dạng
ADDRESS_NOT_WHITELISTED403Địa chỉ rút ngoài whitelist
RISK_REJECTED403Bị từ chối bởi kiểm soát rủi ro
MANUAL_REVIEW_REQUIRED202Lệnh rút cần duyệt tay (trả payout_id PENDING)
PIN_NOT_SET422Chưa set PIN (set trong dashboard)
INVALID_PIN401PIN sai (details.attempts)
TEMP_LOCKED423PIN sai 5 lần → tạm khoá 5 phút (details.lockedUntil)
CASH_IN_QUOTA_EXCEEDED402Vượt hạn mức nạp của gói
CASH_OUT_QUOTA_EXCEEDED402Vượt hạn mức rút của gói
NO_ACTIVE_PACKAGE402Chưa có gói dịch vụ active
RPC_UNAVAILABLE502Mạng blockchain tạm lỗi (thử lại)
MAINTENANCE503Hệ thống đang bảo trì (kèm Retry-After)
INTERNAL500Lỗi hệ thống