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ềnbankopenapi.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
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=| Header | Nội dung |
|---|---|
webhook-id | ID sự kiện (evt_…), giống trường id trong body. Giữ nguyên qua mọi lần retry. |
webhook-timestamp | Thờ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-signature | Chữ ký v1,<base64>. Xem phần verify bên dưới. |
user-agent | BankOpenAPI-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).
{
"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:
npm install standardwebhookscomposer require standard-webhooks/standard-webhookspip install standardwebhooksVerify 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.
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);<?php
// composer require standard-webhooks/standard-webhooks (PHP 8+)
require __DIR__ . '/vendor/autoload.php';
use StandardWebhooks\Webhook;
use StandardWebhooks\Exception\WebhookVerificationException;
$payload = file_get_contents('php://input');
// Thư viện đọc header bằng tên chữ thường: webhook-id, webhook-timestamp, webhook-signature
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$wh = new Webhook(getenv('BANKOPENAPI_WEBHOOK_SECRET'));
$event = $wh->verify($payload, $headers);
} catch (WebhookVerificationException $e) {
http_response_code(400);
exit('Invalid signature');
}
// Chống xử lý trùng: một sự kiện giữ nguyên id qua mọi lần retry
if (alreadyProcessed($event['id'])) {
http_response_code(200);
exit;
}
if ($event['type'] === 'payment_request.paid') {
markOrderPaid($event['data']['orderId'], $event['data']);
}
markProcessed($event['id']);
http_response_code(200);import os
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
# Signing secret của endpoint (dạng "whsec_..."), lấy ở trang Webhook trong app
wh = Webhook(os.environ["BANKOPENAPI_WEBHOOK_SECRET"])
@app.post("/webhooks/bankopenapi")
def bankopenapi_webhook():
try:
# get_data() trả body thô (bytes), đúng chuỗi BankOpenAPI đã ký
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "Invalid signature", 400
# Chống xử lý trùng: một sự kiện giữ nguyên id qua mọi lần retry
if already_processed(event["id"]):
return "", 200
if event["type"] == "payment_request.paid":
mark_order_paid(event["data"]["orderId"], event["data"])
mark_processed(event["id"])
return "", 200Next.js App Router, Hono, Cloudflare Workers, Bun và Deno đều nhận Request chuẩn Web:
// 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
- Bỏ tiền tố
whsec_của secret rồi giải base64 phần còn lại để có khóa. - Ghép chuỗi
<webhook-id>.<webhook-timestamp>.<body thô>. - Tính HMAC-SHA256 của chuỗi đó bằng khóa, mã hóa base64.
- 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ạngv1,<base64>; khớp một phần là hợp lệ. - Bỏ request có timestamp lệch quá 5 phút.
// 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ã
2xxtrong 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ả
200ngay, đừ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ướng3xxcũng là lỗi: BankOpenAPI không đi theo chuyển hướng, hãy dùng đúng URL cuối. - Trả
410 Gonenghĩ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ửi | Thời điểm |
|---|---|
| 1 | Ngay khi sự kiện xảy ra |
| 2 | 5 giây sau lần 1 |
| 3 | 5 phút sau lần 2 |
| 4 | 30 phút sau lần 3 |
| 5 | 2 giờ sau lần 4 |
| 6 | 5 giờ sau lần 5 |
| 7 | 10 giờ sau lần 6 |
| 8 | 10 giờ sau lần 7 (lần cuối) |
- Endpoint trả
429hoặc503kèm headerRetry-Afterthì 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ằngidtrong 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.
-- 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.testtớ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:3000hoặ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ã.