PayFS Documentation
Phát triển

Liên kết thanh toán

Tạo một Liên kết thanh toán, nhận callback HTTPS, và tách callback khỏi webhook Workspace.

Liên kết thanh toán thu một khoản thanh toán trong một Workspace. Người trả mở trang công khai /l/{publicId}. Bạn tạo liên kết từ Client Portal hoặc API Token.

Open Banking cho phép bỏ trống số tiền. NAPAS bắt buộc số tiền cố định. Người trả không nhập số tiền trên trang công khai.

Tạo liên kết

Quyền cần có là payment_links:write trên Workspace.

curl -X POST "https://api.payfs.vn/v1.1/payment-links/$WORKSPACE_ID" \
  -H "Authorization: Bearer $PAYFS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "bank_account_id": "bank-account-id",
    "callback_url": "https://example.com/payfs/callback",
    "amount": "25000",
    "redirect_url": "https://example.com/return",
    "description": "Hóa đơn 1042"
  }'

Bỏ amount khi Open Banking không cố định số tiền. Không gửi amount rỗng. Số tiền là số nguyên VND, tối thiểu 2.000. Tiền tệ luôn là VND.

callback_url bắt buộc và phải là HTTPS. redirect_url không bắt buộc. Nếu có, cũng phải là HTTPS.

expires_at không bắt buộc. Mặc định là 24 giờ sau lúc tạo. Thời hạn tùy chọn phải ở tương lai và không quá 24 giờ. Không có liên kết không hết hạn.

virtual_account_id chỉ gửi khi tài khoản ngân hàng có tài khoản ảo đang hoạt động.

CLI tương ứng:

payfs payment-links create \
  --workspace "$WORKSPACE_ID" \
  --bank-account bank-account-id \
  --callback-url https://example.com/payfs/callback \
  --amount 25000

Callback và chuyển hướng

Callback URL không phải Redirect URL, và không phải webhook của Workspace.

Khi Liên kết thanh toán hoàn tất, PayFS ghi một sự kiện callback thành công cho liên kết đó. PayFS gửi sự kiện này bằng HTTPS POST với nội dung JSON tới Callback URL. Liên kết hoàn tất khi có credit matched hoặc credit overpaid. PayFS không gửi callback khi liên kết hết hạn, bị hủy hoặc thất bại.

Yêu cầu

POST /payfs/callback HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: PayFS-Payment-Link/1
X-PayFS-Signature: 48cb1f3d63e4949ae29c85f6de0820fc319c23a07251ff3530a10d6ce596d754
X-PayFS-Timestamp: 1790474401
X-PayFS-Event-ID: 7379150348229279744
X-PayFS-Attempt: 1

{"amount":"25000","currency":"VND","id":"7379150348229279744","paid_at":"2026-09-27T02:00:00.000Z","payment_link_id":"7379149980103606272","public_id":"q3Xw9LmB2vRt7YkP0sDf4HjN","reference":"PFSL7K2M9Q4X","status":"success","transaction_id":"7379150347981815808","version":1}

Nội dung là JSON gọn, khóa xếp theo thứ tự bảng chữ cái. Hãy kiểm tra chữ ký trên đúng các byte thô của nội dung. Đừng phân tích rồi tạo lại JSON trước khi kiểm tra.

TrườngKiểuÝ nghĩa
versionnumberPhiên bản payload. Giá trị là 1.
idstringMã sự kiện. Cùng giá trị với X-PayFS-Event-ID.
payment_link_idstringMã nội bộ của Liên kết thanh toán. Dùng mã này với API.
public_idstringMã công khai trong /l/{publicId}. Khác với payment_link_id.
statusstringLuôn là success. Trạng thái của liên kết trên API là succeeded. Đừng trộn hai giá trị này.
amountstringSố tiền PayFS đã nhận, dạng số thập phân, đơn vị VND. Với credit overpaid, số này lớn hơn số tiền của liên kết.
currencystringLuôn là VND.
referencestringMã tham chiếu chuyển khoản của liên kết. Liên kết mới dùng tiền tố PFSL và 8 ký tự tiếp theo (A-Z, 0-9).
transaction_idstringGiao dịch PayFS đã hoàn tất liên kết.
paid_atstringThời điểm ISO 8601 của credit hoàn tất, theo UTC.
HeaderGiá trị
X-PayFS-SignatureChuỗi hex chữ thường của HMAC-SHA256, 64 ký tự. Không có tiền tố.
X-PayFS-TimestampThời gian Unix, tính bằng giây, lúc PayFS ký sự kiện.
X-PayFS-Event-IDMã sự kiện. Cùng giá trị với id trong nội dung.
X-PayFS-AttemptSố thứ tự lần gửi. Lần đầu là 1.

Chữ ký

  1. Đọc X-PayFS-Timestamp và nội dung thô.
  2. Tạo chuỗi ${timestamp}.${body}.
  3. Tính HMAC-SHA256 của chuỗi đó (UTF-8), dùng khóa ký của Workspace làm khóa. Dùng toàn bộ chuỗi khóa, gồm cả tiền tố plsk_.
  4. Mã hóa kết quả thành hex chữ thường. So sánh với X-PayFS-Signature bằng phép so sánh thời gian cố định.

PayFS ký sự kiện một lần, lúc ghi sự kiện. Mỗi lần gửi lại dùng cùng nội dung, X-PayFS-Signature, X-PayFS-Timestamp và X-PayFS-Event-ID. Chỉ X-PayFS-Attempt thay đổi. Vì vậy, timestamp có thể cũ hơn lúc gửi. Chấp nhận timestamp cũ tối đa 24 giờ (86.400 giây). Từ chối timestamp cũ hơn.

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

const REPLAY_WINDOW_SECONDS = 24 * 60 * 60;
const CLOCK_SKEW_SECONDS = 5 * 60;

// rawBody: chuỗi nội dung yêu cầu, đúng như đã nhận.
// secrets: khóa ký hiện tại, và khóa trước đó trong 24 giờ sau khi đổi khóa.
export function verifyPayFSCallback(rawBody, headers, secrets) {
  const signature = String(headers['x-payfs-signature'] ?? '');
  const timestamp = Number(headers['x-payfs-timestamp']);
  if (!/^[0-9a-f]{64}$/.test(signature) || !Number.isInteger(timestamp)) {
    return false;
  }
  const age = Math.floor(Date.now() / 1000) - timestamp;
  if (age > REPLAY_WINDOW_SECONDS || age < -CLOCK_SKEW_SECONDS) {
    return false;
  }
  const received = Buffer.from(signature, 'hex');
  return secrets.some(secret => {
    const expected = createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`, 'utf8')
      .digest();
    return timingSafeEqual(received, expected);
  });
}

Để kiểm tra mã của bạn, dùng yêu cầu mẫu ở trên với khóa plsk_example_do_not_use_in_production. Đặt thời gian hiện tại bằng timestamp mẫu. Chữ ký phải hợp lệ.

Phản hồi, thời gian chờ và gửi lại

  • Trả về mã 2xx bất kỳ để xác nhận sự kiện. Sau đó PayFS dừng gửi.
  • PayFS chờ tối đa 30 giây cho mỗi lần gửi, gồm cả chuyển hướng. Bên vận hành có thể đổi giá trị này bằng WEBHOOK_TIMEOUT (mili giây).
  • PayFS theo tối đa 3 lần chuyển hướng. PayFS kiểm tra lại từng đích chuyển hướng. PayFS từ chối đích có địa chỉ riêng hoặc địa chỉ dành riêng.
  • PayFS gửi lại khi có lỗi mạng, hết thời gian chờ, 408, 429 hoặc 5xx.
  • Các trường hợp sau dừng gửi, và PayFS đánh dấu sự kiện thất bại: mã trạng thái khác, chuyển hướng bị từ chối, đích bị chặn, tên máy chủ không tồn tại, và chứng chỉ TLS không hợp lệ.
  • Mặc định, PayFS gửi tối đa 10 lần. Thời gian chờ trước lần gửi lại bắt đầu từ 10 giây và tăng gấp đôi sau mỗi lần, tối đa 512 giây. Bên vận hành có thể đổi các giá trị này bằng WEBHOOK_MAX_ATTEMPTS, WEBHOOK_INITIAL_DELAY, WEBHOOK_BACKOFF_MULTIPLIER và WEBHOOK_MAX_DELAY.

Xử lý trùng lặp

Mỗi Liên kết thanh toán có tối đa một sự kiện thành công. Credit lặp lại hoặc đồng thời không tạo sự kiện thứ hai. Tuy vậy, endpoint của bạn vẫn có thể nhận cùng một sự kiện nhiều lần, ví dụ khi phản hồi 2xx của bạn không tới PayFS. Lưu X-PayFS-Event-ID (hoặc id). Nếu đã xử lý mã đó, trả về 2xx và không làm gì thêm.

Khóa ký

Chữ ký dùng khóa ký của Workspace. Owner hoặc Admin có tư cách thành viên đang hoạt động có thể xem hoặc đổi khóa trong cài đặt Workspace. Member, Viewer, API Token và CLI không thể xem hoặc đổi khóa.

Đổi khóa chỉ giữ khóa ngay trước đó trong 24 giờ. Lần đổi tiếp theo thay khóa trước đó. PayFS không giữ khóa cũ hơn. Callback đã ghi giữ chữ ký của khóa đang dùng lúc ghi. Lần gửi lại không ký lại. Trong 24 giờ đó, hãy kiểm tra bằng cả khóa hiện tại và khóa trước đó.

Khóa ký không nằm trên trang công khai và không nằm trong query chuyển hướng.

Redirect URL là địa chỉ trình duyệt của người trả có thể mở. Query chỉ thêm trường khi có giá trị: status=success hoặc status=failed, amount, link_id, transaction_id, paid_at. link_id là mã công khai, không phải mã nội bộ của Liên kết thanh toán. Chuyển hướng trình duyệt không phải bằng chứng thanh toán. Hết hạn không tự chuyển hướng. Hết hạn cũng không gọi callback.

Webhook Workspace giữ nguyên, gồm transaction.credit, transaction.debit, order.success và order.failed. PayFS không thêm sự kiện Workspace payment_link.* trong bản này.

Trạng thái

Trạng tháiÝ nghĩa
pendingĐang chờ thanh toán. Trang công khai hiện hướng dẫn.
succeededĐã thanh toán. Không hiện hướng dẫn.
failedĐóng ở trạng thái thất bại. Không hiện hướng dẫn.
expiredHết hạn. Không hiện hướng dẫn.
cancelledĐã hủy. Không hiện hướng dẫn.

Một lần thanh toán bị từ chối trên NAPAS không tự đóng liên kết đang chờ. Có thể thử lại trước khi hết hạn.

Credit matched hoặc overpaid hoàn tất liên kết. Với credit overpaid, liên kết ghi số tiền đã nhận. Credit underpaid, duplicate, late, below_minimum hoặc rejected nằm trong lịch sử thanh toán và không hoàn tất liên kết. Liên kết đã đóng không chuyển lại succeeded vì credit trễ hoặc trùng.

Hủy

Chỉ hủy liên kết đang pending.

curl -X POST "https://api.payfs.vn/v1.1/payment-links/$WORKSPACE_ID/$LINK_ID/cancel" \
  -H "Authorization: Bearer $PAYFS_API_TOKEN"

Trang công khai

Đường dẫn là /l/{publicId}. Trang không hiện mã nội bộ và cũng không hiện mã công khai. Trang cũng không hiện tên Workspace, URL callback, hoặc khóa ký.

Khi đang chờ, trang hiện số tiền nếu có, thời hạn, QR, mã ngân hàng nhận, số tài khoản, chủ tài khoản và mã tham chiếu. Không có ô nhập số tiền. Nếu không cố định số tiền, trang không hiện số tiền và QR không gắn số tiền.

Mất kết nối không có nghĩa là thanh toán thất bại. Hướng dẫn lần cuối vẫn ở lại. Trạng thái đóng thì ẩn hướng dẫn thanh toán.

On this page