UNPKG

@gpmpay/sdk

Version:

Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.

478 lines (350 loc) • 16.4 kB
# 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; } ```