# 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;
}
```
