@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
478 lines (350 loc) • 16.4 kB
Markdown
# Webhook từ A đến Z
[English](../en/03-webhooks.md) · [Mục lục](./README.md)
Webhook là cách chính để biết đơn đã được trả. Polling chỉ là dự phòng.
## 1. Đăng ký endpoint
```ts
const { setting, secret } = await client.webhookSettings.createHmacEndpoint({
url: 'https://shop.example.com/webhooks/gpmpay',
name: 'Production',
});
console.log(secret); // ← chỉ hiện MỘT LẦN
```
Lưu `secret` ngay vào `GPMPAY_WEBHOOK_SECRET`. Server lưu nó ở dạng mã hoá và **không bao giờ trả lại**. Mất thì phải tạo endpoint mới.
Chỉ nhận webhook cho một số tài khoản:
```ts
await client.webhookSettings.createHmacEndpoint({
url: 'https://shop.example.com/webhooks/gpmpay',
scope: 'SPECIFIC',
bankAccountIds: [bankAccountId],
});
```
Cũng có thể tạo bằng UI dashboard — SDK không bắt buộc.
## 2. Payload
```ts
interface WebhookPayload {
id: string; // id giao dịch — DÙNG LÀM KHOÁ IDEMPOTENCY
gateway: string; // mã ngân hàng, vd 'MB'
transactionDate: string; // ISO
accountNumber: string;
subAccount: string | null; // hiện luôn là null
content: string; // nội dung chuyển khoản, cắt còn 100 ký tự
transferType: 'in' | 'out';
transferAmount: number; // đã là number, không phải string
accumulated: number | null; // số dư sau giao dịch, nếu ngân hàng có báo
referenceCode: string; // mã giao dịch CỦA NGÂN HÀNG — không phải mã đơn của bạn
source: 'REAL' | 'SIMULATED';
}
```
Đủ 11 field. Hai điểm dễ nhầm:
- **Mã của bạn nằm trong `content`**, không phải `referenceCode`. `referenceCode` là mã giao dịch của ngân hàng, không liên quan gì đến đơn hàng — dò theo nó thì không bao giờ khớp. Đây là lỗi tích hợp phổ biến nhất.
- **Bạn nhận webhook cho *mọi* giao dịch**, kể cả tiền ra và tiền vào không liên quan gì đến bạn. Hãy chặn bằng `transferType === 'in'` và bằng chính mã của bạn.
```ts
const { content, transferAmount, transferType } = event.payload;
if (transferType !== 'in') return;
const code = /DH(\d+)/.exec(content)?.[0]; // dùng regex, không dùng `===`
if (!code) return; // tiền vào nhưng không mang mã của bạn
const order = await db.orders.findByCode(code);
if (!order || order.total !== transferAmount) return; // so tiền là việc của bạn
await giaoHang(order);
```
## 3. Xác thực chữ ký
Header GPM Pay gửi kèm **phụ thuộc vào `authorizationType`** của webhook setting. Ba chế độ dùng ba header khác nhau — chọn nhầm là đi tìm một header không bao giờ tồn tại:
| `authorizationType` | Header GPM Pay gửi | Cách kiểm tra |
|---|---|---|
| `HMAC` *(mặc định)* | `X-GPMPay-Signature: t=<unix>,v1=<hex>` | `constructWebhookEvent()` |
| `API_KEY` | Header bạn tự đặt qua `authorizationHeaderName`, **mặc định `Authorization`** | `verifyApiKeyHeader()` |
| `NONE` | Không có header xác thực nào, chỉ `Content-Type` | Không xác thực được |
Ba điểm hay bị hiểu nhầm:
- **Không tồn tại header `X-GPMPay-Timestamp`.** Timestamp nằm trong `t=` bên trong giá trị chữ ký.
- **Driver `HTTP` không gửi `X-GPMPay-Event`** — chỉ driver WordPress gửi. `event.type` mặc định `'transaction.created'` là giá trị SDK tự điền, không phải thứ đi trên dây.
- Enum là `HMAC`, **không phải** `HMAC_SHA256`. Thuật toán là SHA-256, tên enum thì không.
Xem endpoint của mình đang ở chế độ nào:
```bash
gpmpay webhook settings
```
### HMAC — mặc định, và là thứ bạn nên dùng
```
X-GPMPay-Signature: t=1785600000,v1=3f2a9c...64_ký_tự_hex
```
Chữ ký là `HMAC-SHA256(secret, "${t}.${rawBody}")`, cửa sổ lệch giờ ±300 giây. Xem §4 trở đi cho code từng framework.
### API_KEY — secret thô trong header
GPM Pay gửi **đúng secret, không bọc prefix `Bearer` hay `ApiKey`**. Nghĩa là header trông giống một Bearer token nhưng không phải — đừng `slice('Bearer '.length)`.
```ts
import { verifyApiKeyHeader } from '@gpmpay/sdk/webhooks';
// Đăng ký: header mặc định là `Authorization` nếu không đặt authorizationHeaderName
await client.webhookSettings.create({
driver: 'HTTP',
url: 'https://shop.example.com/webhooks/gpmpay',
scope: 'ALL',
authorizationType: 'API_KEY',
authorizationHeaderName: 'X-Api-Key',
authorizationSecret: process.env.GPMPAY_WEBHOOK_SECRET!,
});
// Nhận:
if (!verifyApiKeyHeader(req.headers['x-api-key'], process.env.GPMPAY_WEBHOOK_SECRET!)) {
return res.status(401).end();
}
```
`verifyApiKeyHeader` so sánh constant-time. **Đừng** dùng `===` — so sánh string thường thoát sớm ở byte đầu tiên khác nhau và làm rò rỉ độ dài prefix đúng.
API_KEY yếu hơn HMAC: không có timestamp nên không chống replay, và secret đi trên dây ở mỗi lần giao thay vì chỉ chữ ký. Chỉ dùng khi hệ thống nhận không tự HMAC được.
### NONE — không xác thực
Không có header nào. Bất kỳ ai biết URL đều giả được webhook. Chỉ dùng cho endpoint trong mạng nội bộ, không public ra Internet.
### Quy tắc bất di bất dịch: phải dùng raw body
HMAC ký trên **đúng dãy byte** mà server gửi. Nếu framework đã parse JSON rồi bạn `JSON.stringify` lại, thứ tự key và khoảng trắng đổi → chữ ký **luôn** sai.
SDK phát hiện tình huống này và ném lỗi cấu hình giải thích rõ, thay vì để bạn ngồi debug một chữ ký "sai vô cớ".
## 4. Code theo framework
Mở đúng framework của bạn. Mọi khối đều làm cùng một việc: lấy **raw body**, verify, xử lý, trả 200 nhanh.
<details open>
<summary><b>Express</b></summary>
```ts
import express from 'express';
import { gpmpayWebhook } from '@gpmpay/sdk/webhooks';
app.post(
'/webhooks/gpmpay',
express.raw({ type: 'application/json' }), // ← BẮT BUỘC, chỉ cho route này
gpmpayWebhook({
secret: process.env.GPMPAY_WEBHOOK_SECRET!,
onEvent: async (event) => {
const code = /DH(\d+)/.exec(event.payload.content)?.[0];
if (code) await giaoHang(code, event.payload.id);
},
onError: (error) => {
logger.warn({ reason: error.reason }, 'webhook GPM Pay bị từ chối');
},
}),
);
```
Nếu app đã có `express.json()` toàn cục, đừng gỡ nó — bắt raw body qua hook `verify`:
```ts
app.use(express.json({
verify: (req, _res, buf) => { (req as any).rawBody = buf; },
}));
```
Middleware tự ưu tiên `req.rawBody` nếu có.
</details>
<details>
<summary><b>Next.js — App Router</b></summary>
```ts
// app/api/webhooks/gpmpay/route.ts
import { createNextWebhookHandler } from '@gpmpay/sdk/webhooks';
export const POST = createNextWebhookHandler({
secret: process.env.GPMPAY_WEBHOOK_SECRET!,
onEvent: async (event) => {
const code = /DH(\d+)/.exec(event.payload.content)?.[0];
if (code) await giaoHang(code);
},
});
```
`await request.text()` cho đúng byte thô nên không cần cấu hình gì thêm.
Cần kiểm soát nhiều hơn:
```ts
import { verifyNextRequest } from '@gpmpay/sdk/webhooks';
import { GpmPayWebhookSignatureError } from '@gpmpay/sdk';
export async function POST(request: Request) {
try {
const event = await verifyNextRequest(request, {
secret: process.env.GPMPAY_WEBHOOK_SECRET!,
});
// ...
return Response.json({ received: true });
} catch (error) {
if (error instanceof GpmPayWebhookSignatureError) {
return Response.json({ error: error.reason }, { status: 401 });
}
throw error;
}
}
```
</details>
<details>
<summary><b>Next.js — Pages Router</b></summary>
```ts
import { readRawBody, constructWebhookEvent } from '@gpmpay/sdk/webhooks';
export const config = { api: { bodyParser: false } }; // ← BẮT BUỘC
export default async function handler(req, res) {
const rawBody = await readRawBody(req);
const event = constructWebhookEvent({
rawBody,
signature: req.headers['x-gpmpay-signature'],
secret: process.env.GPMPAY_WEBHOOK_SECRET!,
headers: req.headers,
});
res.status(200).json({ received: true });
}
```
</details>
<details>
<summary><b>Fastify</b></summary>
Đăng ký parser giữ nguyên buffer, rồi verify như mọi framework khác:
```ts
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(_req, body, done) => done(null, body),
);
fastify.post('/webhooks/gpmpay', async (req, reply) => {
const event = constructWebhookEvent({
rawBody: req.body as Buffer,
signature: req.headers['x-gpmpay-signature'] as string,
secret: process.env.GPMPAY_WEBHOOK_SECRET!,
});
await reply.send({ received: true });
});
```
</details>
<details>
<summary><b>Hono / Cloudflare Workers / Deno</b></summary>
```ts
app.post('/webhooks/gpmpay', async (c) => {
const event = constructWebhookEvent({
rawBody: await c.req.text(), // đã là byte thô
signature: c.req.header('x-gpmpay-signature') ?? '',
secret: c.env.GPMPAY_WEBHOOK_SECRET,
});
return c.json({ received: true });
});
```
</details>
<details>
<summary><b>Framework bất kỳ</b></summary>
Lấy được raw body rồi thì mọi thứ như nhau:
```ts
import {
assertWebhookSignature,
constructWebhookEvent,
verifyWebhookSignature,
} from '@gpmpay/sdk/webhooks';
// Có payload đã parse kiểu:
const event = constructWebhookEvent({ rawBody, signature, secret, headers });
// Chỉ cần boolean:
const ok = verifyWebhookSignature({ rawBody, signature, secret });
// Cần biết lý do hỏng:
try {
assertWebhookSignature({ rawBody, signature, secret });
} catch (error) {
error.reason; // 'missing_secret' | 'malformed_header' | 'timestamp_skew' | 'mismatch'
}
```
</details>
### Tuỳ chọn của `gpmpayWebhook` (Express)
| Option | Mặc định | Ý nghĩa |
|---|---|---|
| `secret` | — | secret của webhook setting |
| `onEvent` | — | handler; nhận `(event, req)` |
| `onError` | — | gọi khi chữ ký sai, trước khi trả 401 |
| `toleranceSeconds` | `300` | cửa sổ lệch giờ |
| `headerName` | `X-GPMPay-Signature` | đổi nếu bạn cấu hình `authorizationHeaderName` khác |
| `respondEarly` | `true` | trả 200 trước khi `await onEvent` |
## 5. Hai tính chất bắt buộc của handler
### 5a. Idempotent theo `payload.id`
GPM Pay retry theo lịch `10s → 30s → 2m → 10m → 1h → 6h`, tối đa **6 lần**. Cùng một giao dịch **sẽ** tới nhiều lần bất cứ khi nào lần đầu chậm hoặc lỗi.
```ts
onEvent: async (event) => {
const txnId = event.payload.id;
// Chèn trước, khoá bằng unique constraint
const inserted = await db.processedWebhooks.insertIfAbsent(txnId);
if (!inserted) return; // đã xử lý rồi
const code = /DH(\d+)/.exec(event.payload.content)?.[0];
if (code) {
await giaoHang(code);
}
}
```
Hằng số dùng được trong code:
```ts
import { WEBHOOK_RETRY_SCHEDULE_SECONDS, WEBHOOK_MAX_ATTEMPTS } from '@gpmpay/sdk/webhooks';
// [10, 30, 120, 600, 3600, 21600] · 6
```
### 5b. Trả lời trong 5 giây
Server huỷ delivery sau **5000ms**. Handler chậm = bị coi là fail = retry = xử lý trùng.
`gpmpayWebhook` đã trả `200` **trước khi** `await onEvent` (`respondEarly: true`). Việc nặng nên đẩy vào queue:
```ts
onEvent: async (event) => {
await queue.add('fulfil-order', { transactionId: event.payload.id });
}
```
Tự viết handler thì tự đảm bảo tính chất này.
## 6. Debug trên máy local
### Bắn thẳng vào handler của bạn — không cần token, không cần ngrok
```bash
npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \
--secret $GPMPAY_WEBHOOK_SECRET
```
Lệnh này ký một payload mẫu rồi POST vào URL bạn chỉ định. Nó **không gọi API GPM Pay** nên cũng không cần `GPMPAY_API_TOKEN` — dùng được ngay từ phút đầu, trước cả khi có tài khoản.
Handler của bạn phải **từ chối** hai lệnh dưới đây. Nếu nó trả 200 thì việc verify đang không hoạt động:
```bash
npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \
--secret $GPMPAY_WEBHOOK_SECRET --bad-signature # chữ ký sai → phải 401
npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \
--secret $GPMPAY_WEBHOOK_SECRET --skew 600 # ngoài cửa sổ ±300s → phải 401
```
Các cờ khác: `--amount`, `--content` (thử nội dung tiếng Việt có dấu — đây là chỗ code tự viết hay hỏng), `--content "CT DEN DH123"` để thử nhánh khớp mã, `--file body.json` để gửi body của chính bạn.
### Nhận webhook thật qua ngrok
```bash
npx gpmpay webhook listen --port 4444 --secret $GPMPAY_WEBHOOK_SECRET
```
```
✓ Listening for GPM Pay webhooks on http://localhost:4444
```
Server này verify từng request thật và in payload đã format. Trả `200` nếu hợp lệ, `401` nếu không — đúng những gì endpoint của bạn cần làm.
Kèm ngrok + simulator là có demo end-to-end trong một phút:
```bash
ngrok http 4444
npx gpmpay accounts list # lấy bankAccountId
npx gpmpay simulate tx --sandbox --account <uuid> --amount 50000 --content TEST
```
Verify một chữ ký lẻ khi soi log:
```bash
echo '{"id":"tx_1"}' | npx gpmpay webhook verify \
--secret $GPMPAY_WEBHOOK_SECRET \
--signature "t=1785600000,v1=3f2a9c..."
```
## 7. Xem lịch sử giao
```bash
npx gpmpay webhook settings # endpoint nào đang bật, chế độ xác thực gì
npx gpmpay webhook history --status FAILED
npx gpmpay webhook retry <history-id>
```
`webhook history` in mã HTTP endpoint trả về, số lần đã thử, thời gian phản hồi và phần đầu của response body — đủ để phân biệt "handler trả 500" với "handler timeout" mà không cần vào dashboard.
Tương đương trong code:
```ts
const history = await client.webhookHistories.list({
status: 'FAILED',
settingId: setting.id,
});
await client.webhookHistories.retry(history.data[0]!.id); // giao lại thủ công
```
Không retry được đơn đã `DELIVERED`, và setting đang tắt thì phải bật lại trước.
## 8. Sự cố thường gặp
| Triệu chứng | Nguyên nhân |
|---|---|
| Luôn `mismatch` dù secret đúng | Body đã bị parse — thiếu `express.raw()` / `bodyParser: false` |
| `mismatch` chỉ với nội dung tiếng Việt | Bạn tự viết verify và hash trên string thay vì Buffer. Dùng hàm của SDK. |
| `timestamp_skew` | Đồng hồ server lệch. Bật NTP. |
| `malformed_header` | Header không tới được (proxy strip) hoặc sai tên header |
| Giao dịch xử lý 2–3 lần | Handler chưa idempotent, hoặc trả lời quá 5 giây |
| Không nhận được webhook nào | Setting `isActive: false`, sai scope tài khoản, hoặc URL không public |
| Nhận webhook nhưng `order` là `null` | Giao dịch không khớp đơn nào — đúng theo thiết kế, không phải lỗi |
Với giao dịch giả lập, nhớ webhook setting phải bật `fireOnSimulated: true` (mặc định đã bật).
## 9. Bảo mật
- **Luôn verify chữ ký.** Endpoint là public; ai cũng POST vào được.
- Secret nằm trong biến môi trường, không commit.
- Đừng dùng IP allowlist — webhook có thể đi qua proxy egress nên IP nguồn không cố định.
- Tin `event.payload`, đừng tin query string hay bất cứ thứ gì ngoài phần đã ký.
- Trước khi giao hàng, đối chiếu số tiền với đơn trong DB của bạn:
```ts
const local = await db.orders.findByCode(code);
if (!local || local.amount !== event.payload.transferAmount) {
logger.error({ order }, 'số tiền webhook không khớp đơn nội bộ');
return;
}
```