# Xác minh IPN

Mọi lời gọi IPN đều có chữ ký, để endpoint của bạn phân biệt được lời gọi thật từ Hạ Tầng với lời gọi giả mạo. Trang này giải thích chữ ký, đưa ra handler chạy được bằng bốn ngôn ngữ, một test vector để kiểm tra code, và những lỗi hay làm hỏng việc xác minh.

## Các khoá

Mỗi doanh nghiệp có một **access key** và một **secret key** cho mỗi môi trường, được tạo cùng doanh nghiệp. Xem trên dashboard tại **Cài đặt thanh toán → Khoá tích hợp**. Key development bắt đầu bằng `ak_test_` và `sk_test_`.

- `Hatang-Access-Key` cho biết lời gọi thuộc tài khoản và môi trường nào.
- `Hatang-Signature` là **HMAC-SHA256 dạng hex thường của body gốc**, khóa là secret key (dạng byte UTF-8, đúng như hiển thị).

Để xác minh: tính HMAC trên đúng các byte nhận được, rồi so với header bằng hàm so sánh thời gian hằng. Chỉ giữ secret key ở server.

## Ví dụ handler

**Node.js**

```js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.HATANG_SECRET_KEY; // sk_…

function verifyIpn(rawBody, signature, secret) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const got = String(signature ?? "").trim().toLowerCase();
  return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}

const app = express();
// Byte gốc, không phải JSON đã parse: serialize lại sẽ đổi body và làm sai chữ ký.
app.post("/hatang/ipn", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verifyIpn(req.body, req.get("Hatang-Signature"), SECRET)) return res.status(401).end();
  const ipn = JSON.parse(req.body.toString("utf8"));
  if (await alreadyHandled(ipn.id)) return res.status(200).end();
  await queue.add("hatang-ipn", ipn); // cập nhật đơn ở nền
  res.status(200).end();
});
```

Với SDK: `constructIpnEvent(rawBody, headers, secretKey)` xác minh và trả về lời gọi đã parse (hoặc throw `HatangSignatureError`), còn `verifyIpn(rawBody, signature, secretKey)` trả về boolean. Xem [SDK JavaScript](https://hatang.dev/vi/docs/guides/payments/sdk-javascript.md).

**Python**

```python
import os
from flask import Flask, request, abort
from hatang import HatangSignatureError, construct_ipn_event

SECRET = os.environ["HATANG_SECRET_KEY"]  # sk_…
app = Flask(__name__)

@app.post("/hatang/ipn")
def hatang_ipn():
    try:
        # byte, đúng như nhận được; kiểm tra Hatang-Signature
        ipn = construct_ipn_event(request.get_data(), request.headers, SECRET)
    except HatangSignatureError:
        abort(401)
    # chống trùng theo ipn["id"], rồi cập nhật đơn theo ipn["data"]["status"]
    return "", 200
```

**PHP**

```php
<?php
$secret = getenv('HATANG_SECRET_KEY'); // sk_…
$raw = file_get_contents('php://input');
$signature = strtolower(trim($_SERVER['HTTP_HATANG_SIGNATURE'] ?? ''));
$expected = hash_hmac('sha256', $raw, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}
$ipn = json_decode($raw, true);
// chống trùng theo $ipn['id'], rồi cập nhật đơn theo $ipn['data']['status']
http_response_code(200);
```

**C#**

```csharp
using System.Security.Cryptography;
using System.Text;

app.MapPost("/hatang/ipn", async (HttpRequest req) =>
{
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);
    var raw = ms.ToArray();
    var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("HATANG_SECRET_KEY")!);
    var expected = Convert.ToHexString(HMACSHA256.HashData(secret, raw)).ToLowerInvariant();
    var got = req.Headers["Hatang-Signature"].ToString().Trim().ToLowerInvariant();
    if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(got)))
        return Results.Unauthorized();
    // chống trùng theo trường "id", rồi cập nhật đơn
    return Results.Ok();
});
```

## Test vector

Với secret key `sk_test_example` và body `{"id":"ipn_test","type":"payment.succeeded"}` (đúng các byte đó), chữ ký là:

```
35c86d268e66be5f34c25d834de9da3ba1f0c6d26fb735266440886df6b5d955
```

Kiểm tra bằng shell:

```bash
printf '%s' '{"id":"ipn_test","type":"payment.succeeded"}' | openssl dgst -sha256 -hmac sk_test_example
```

Và gửi một lời gọi test có chữ ký tới handler trên máy bạn:

```bash
BODY='{"id":"ipn_test","type":"payment.succeeded"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac sk_test_example | awk '{print $NF}')
curl -i http://localhost:3000/hatang/ipn \
  -H "Content-Type: application/json" \
  -H "Hatang-Signature: $SIG" \
  --data-raw "$BODY"
```

Với `HATANG_SECRET_KEY=sk_test_example`, handler phải trả `200`; đổi một ký tự trong body thì phải trả `401`. Để test trọn luồng, tạo payment ở development và [giả lập chuyển khoản](https://hatang.dev/vi/docs/guides/payments/environments.md#simulate-a-transfer).

## Lỗi thường gặp

- **Parse JSON rồi serialize lại trước khi xác minh.** Khoảng trắng và thứ tự key thay đổi, HMAC không khớp. Hãy xác minh trên byte gốc. Với Express, dùng `express.raw()` cho route này; với Next.js, đọc `await req.text()`.
- **Dùng secret key của môi trường kia.** Development và production có key khác nhau. `Hatang-Environment` và tiền tố `sk_test_` cho biết key nào áp dụng.
- **So sánh bằng `==`.** Hãy dùng hàm so sánh thời gian hằng.
- **Proxy làm thay đổi body.** Một số gateway mã hóa lại JSON hoặc đổi bảng mã. Hãy xác minh trên body đúng như Hạ Tầng gửi.
- **Xoay secret key.** Sau khi bấm **Tạo secret key mới** trên dashboard, lời gọi được ký bằng key mới ngay lập tức. Các lần thử lại của lời gọi cũ được ký bằng key hiện hành lúc gửi, nên hãy đổi key trên server ngay sau khi xoay.

## Bước tiếp theo

- [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md): làm gì sau khi lời gọi đã được xác minh.
- [Webhook](https://hatang.dev/vi/docs/guides/payments/webhooks.md): cơ chế chữ ký còn lại.
- [Lên production](https://hatang.dev/vi/docs/guides/payments/go-live.md).

Source: https://hatang.dev/vi/docs/guides/payments/verify-ipn
