Nhận và verify webhook

Khi có tiền vào hay đơn được thanh toán, BankOpenAPI gửi một request POST có body JSON tới endpoint của bạn. Định dạng theo chuẩn mở Standard Webhooks, nên verify được bằng thư viện có sẵn cho hầu hết ngôn ngữ.

Tạo endpoint

Vào trang Webhook trong app, bấm "Thêm endpoint":

  • URL: bắt đầu bằng https://, trỏ tới địa chỉ công khai trên Internet, tối đa 2.048 ký tự. Không dùng localhost, IP nội bộ, tên miền bankopenapi.com, phần #… hay tên đăng nhập trong URL.
  • Sự kiện: chọn từng loại, hoặc "Tất cả" (*) để nhận cả các loại thêm về sau. Xem Sự kiện và payload.
  • Signing secret: mỗi endpoint một secret dạng whsec_…, dùng để verify chữ ký. Lưu trong biến môi trường, không commit lên Git.

Mỗi tài khoản có tối đa 10 endpoint. Một sự kiện được gửi tới mọi endpoint đang bật có đăng ký loại đó. Hiện quản lý endpoint trong app hoặc qua API webhook endpoint.

Request BankOpenAPI gửi

Header
POST /webhooks/bankopenapi HTTP/1.1
content-type: application/json
user-agent: BankOpenAPI-Webhooks/1.0
webhook-id: evt_2Gc7Vn4Kp9Wq3Xr8Ty5Bm1Hd
webhook-timestamp: 1793849092
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
HeaderNội dung
webhook-idID sự kiện (evt_…), giống trường id trong body. Giữ nguyên qua mọi lần retry.
webhook-timestampThời điểm gửi lần này, tính bằng giây Unix. Mỗi lần retry có giá trị mới.
webhook-signatureChữ ký v1,<base64>. Xem phần verify bên dưới.
user-agentBankOpenAPI-Webhooks/1.0

Body luôn có các trường: id, type, livemode (false với sự kiện của sandbox), timestamp (lúc sự kiện xảy ra, ISO 8601) và data (đối tượng liên quan).

Body
{
  "id": "evt_2Gc7Vn4Kp9Wq3Xr8Ty5Bm1Hd",
  "type": "payment_request.paid",
  "livemode": true,
  "timestamp": "2026-11-05T03:24:52.000Z",
  "data": {
    "id": "pr_3fK9mQ2xLwA7nB5vR8tY1cZd",
    "object": "payment_request",
    "livemode": true,
    "amount": 150000,
    "amountReceived": 150000,
    "currency": "VND",
    "status": "paid",
    "code": "TT7K3M9Q",
    "orderId": "DH1028",
    "description": "Đơn hàng DH1028",
    "bankAccount": {
      "id": "ba_8Hn2PqR5sT7vW9xY3zA4bC6d",
      "bank": {
        "bin": "970422",
        "name": "MB Bank"
      },
      "accountNumber": "0123456789",
      "label": "MB Bank •••• 6789"
    },
    "transfer": {
      "bankBin": "970422",
      "accountNumber": "0123456789",
      "accountName": "NGUYEN VAN AN",
      "amount": 0,
      "content": "TT7K3M9Q"
    },
    "qr": null,
    "hostedUrl": "https://app.bankopenapi.com/thanh-toan/pr_3fK9mQ2xLwA7nB5vR8tY1cZd",
    "paymentLinkId": null,
    "expiresAt": "2026-11-06T03:20:11.000Z",
    "paidAt": "2026-11-05T03:24:52.000Z",
    "canceledAt": null,
    "createdAt": "2026-11-05T03:20:11.000Z"
  }
}

Verify chữ ký

Verify mọi request trước khi tin dữ liệu bên trong: ai biết URL endpoint cũng gửi được request tới đó. Cài thư viện standardwebhooks:

Cài thư viện
npm install standardwebhooks

Verify trên body thô, đúng chuỗi byte nhận được. Parse JSON rồi stringify lại sẽ đổi khoảng trắng, thứ tự khóa và làm chữ ký sai.

Endpoint nhận webhook
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
// Signing secret của endpoint (dạng "whsec_..."), lấy ở trang Webhook trong app
const wh = new Webhook(process.env.BANKOPENAPI_WEBHOOK_SECRET);

// express.raw giữ nguyên body thô: verify phải chạy trên đúng chuỗi BankOpenAPI đã ký
app.post("/webhooks/bankopenapi", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, req.headers);
  } catch {
    return res.status(400).send("Invalid signature");
  }

  // Chống xử lý trùng: một sự kiện giữ nguyên event.id qua mọi lần retry
  if (await alreadyProcessed(event.id)) return res.sendStatus(200);

  if (event.type === "payment_request.paid") {
    await markOrderPaid(event.data.orderId, event.data);
  }

  await markProcessed(event.id);
  res.sendStatus(200);
});

app.listen(3000);

Next.js App Router, Hono, Cloudflare Workers, Bun và Deno đều nhận Request chuẩn Web:

TypeScript
// app/api/webhooks/bankopenapi/route.ts (Next.js). Hono, Cloudflare Workers, Bun làm tương tự.
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.BANKOPENAPI_WEBHOOK_SECRET!);

export async function POST(req: Request) {
  const body = await req.text(); // đọc body thô, đừng dùng req.json() trước khi verify
  let event: { id: string; type: string; data: any };
  try {
    event = wh.verify(body, Object.fromEntries(req.headers)) as typeof event;
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
  // ... chống trùng theo event.id, xử lý theo event.type ...
  return new Response(null, { status: 200 });
}

Thư viện từ chối request có webhook-timestamp lệch quá 5 phút so với giờ hệ thống của bạn (chống gửi lại request cũ), nên giữ đồng hồ hệ thống đúng giờ (NTP).

Tự verify không dùng thư viện

  1. Bỏ tiền tố whsec_ của secret rồi giải base64 phần còn lại để có khóa.
  2. Ghép chuỗi <webhook-id>.<webhook-timestamp>.<body thô>.
  3. Tính HMAC-SHA256 của chuỗi đó bằng khóa, mã hóa base64.
  4. So sánh (so sánh hằng thời gian) với từng chữ ký trong header webhook-signature. Header là danh sách cách nhau bằng dấu cách, mỗi phần dạng v1,<base64>; khớp một phần là hợp lệ.
  5. Bỏ request có timestamp lệch quá 5 phút.
Node.js
// Tự verify khi ngôn ngữ của bạn chưa có thư viện (Node.js để minh họa)
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  // Chống replay: bỏ request lệch quá 5 phút
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  // Header có thể chứa nhiều chữ ký cách nhau bằng dấu cách: "v1,abc... v1,def..."
  return headers["webhook-signature"].split(" ").some((part) => {
    const [version, sig] = part.split(",");
    const given = Buffer.from(sig ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Phản hồi và timeout

  • Trả mã 2xx trong vòng 10 giây là thành công. Body phản hồi không quan trọng.
  • Việc nặng (gửi email, gọi API khác) thì đưa vào hàng đợi rồi trả 200 ngay, đừng làm xong mới trả lời.
  • Mã khác 2xx, quá 10 giây hoặc không kết nối được thì tính là lỗi và được retry. Chuyển hướng 3xx cũng là lỗi: BankOpenAPI không đi theo chuyển hướng, hãy dùng đúng URL cuối.
  • Trả 410 Gone nghĩa là endpoint không nhận nữa: BankOpenAPI tắt endpoint và ngừng gửi. Bật lại trong app khi sẵn sàng.
  • Mọi lần gửi tới endpoint đều lỗi trong 5 ngày liền (không lần nào thành công) thì BankOpenAPI tự tắt endpoint và email báo bạn. Sự kiện phát sinh trong lúc endpoint tắt vẫn được lưu: sửa hệ thống xong, bật lại rồi bấm "Gửi lại sự kiện lỗi" để gửi bù.

Lịch retry

Mỗi sự kiện được gửi tối đa 8 lần, trải trong khoảng 27 giờ:

Lần gửiThời điểm
1Ngay khi sự kiện xảy ra
25 giây sau lần 1
35 phút sau lần 2
430 phút sau lần 3
52 giờ sau lần 4
65 giờ sau lần 5
710 giờ sau lần 6
810 giờ sau lần 7 (lần cuối)
  • Endpoint trả 429 hoặc 503 kèm header Retry-After thì lần sau đợi ít nhất số giây đó (tối đa 12 giờ).
  • Hết 8 lần vẫn lỗi thì lần gửi được đánh dấu thất bại. Xem lịch sử gửi (mã HTTP, thời gian, 1 KB đầu của phản hồi) và bấm "Gửi lại" trên trang Webhook. Muốn gửi lại cả loạt (vd. sau khi hệ thống sập vài giờ) thì bấm "Gửi lại sự kiện lỗi" và chọn khoảng thời gian: BankOpenAPI gửi lại các lần gửi thất bại và gửi bù sự kiện phát sinh lúc endpoint tắt.
  • Mọi sự kiện được lưu 30 ngày, kể cả khi chưa có endpoint nào nhận, cũ hơn thì bị xóa. Xem lại ở tab "Sự kiện" của trang Webhook hoặc qua GET /v1/events.
  • Hệ thống của bạn lỡ mất sự kiện quá lâu: dùng GET /v1/payment-requests để đối chiếu trạng thái các đơn.

Chống xử lý trùng

Một sự kiện có thể tới endpoint của bạn hơn một lần: hệ thống xử lý xong nhưng trả lời quá 10 giây nên bị retry, hoặc ai đó bấm "Gửi lại". Hệ thống của bạn phải idempotent, tức nhận hai lần cũng không cộng tiền hai lần.

  • Dùng webhook-id (bằng id trong body) làm khóa chống trùng. Giá trị này giữ nguyên qua mọi lần gửi lại.
  • Ghi ID sự kiện vào một bảng có khóa duy nhất, trong cùng transaction với việc cập nhật đơn hàng.
  • Đã thấy ID này thì bỏ qua và vẫn trả 200, để BankOpenAPI ngừng retry.
  • Cập nhật đơn theo điều kiện trạng thái (WHERE status <> 'paid'), phòng hai sự kiện khác nhau cùng nói về một đơn.
SQL
-- Mỗi sự kiện chỉ xử lý một lần: id là webhook-id (cũng là "id" trong body)
CREATE TABLE webhook_events (
  id          VARCHAR(64) PRIMARY KEY,
  type        VARCHAR(64) NOT NULL,
  received_at TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Trong cùng một transaction với việc cập nhật đơn hàng:
INSERT INTO webhook_events (id, type) VALUES ($1, $2);   -- trùng khóa thì dừng, trả 200
UPDATE orders SET status = 'paid' WHERE id = $3 AND status <> 'paid';

Thứ tự sự kiện

Sự kiện được gửi song song nên có thể tới không theo thứ tự xảy ra. Ví dụ với cùng một khoản chuyển, payment_request.paid có thể tới trước transaction.received. Quyết định dựa vào data.status và timestamp, không dựa vào thứ tự nhận. Cần chắc chắn thì gọi GET /v1/payment-requests/:id để lấy trạng thái mới nhất.

Kiểm thử

  • Bấm "Gửi thử" trên trang Webhook: BankOpenAPI gửi sự kiện webhook.test tới đúng endpoint đó, ký như sự kiện thật. Gửi thử chỉ gửi một lần, lỗi thì báo ngay, không retry.
  • Đang chạy trên máy: mở tunnel để có URL https công khai, ví dụ cloudflared tunnel --url http://localhost:3000 hoặc ngrok, rồi khai báo URL đó làm endpoint tạm.
  • Thử thật: tạo yêu cầu thanh toán 1.000 VND rồi tự chuyển khoản đúng mã.

Bắt đầu với BankOpenAPI

Đăng ký tài khoản, liên kết tài khoản ngân hàng và nhận webhook báo có đầu tiên. Cần tư vấn thì nhắn chúng tôi.