# Webhook

Webhook gửi các sự kiện Thanh toán của toàn doanh nghiệp tới một hoặc nhiều endpoint: mọi payment, mọi giao dịch ngân hàng, mọi thay đổi tài khoản ngân hàng và hóa đơn điện tử, ở cả hai môi trường. Dùng webhook cho giao dịch không qua đơn (`transaction.received`) và những gì IPN không bao quát. Trang này hướng dẫn tạo endpoint, xác minh lần gửi, và liệt kê mọi sự kiện Thanh toán.

Cách webhook hoạt động trên mọi sản phẩm Hạ Tầng được mô tả ở [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md).

## Tạo endpoint

Trên dashboard, mở **Webhooks** và thêm endpoint, hoặc gọi `POST /webhooks`. Một endpoint nhận cả hai môi trường; mỗi object thanh toán cho biết môi trường qua trường `environment`.

**cURL**

```bash
curl https://api.hatang.dev/webhooks \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://shop.example.vn/hatang/webhooks",
    "events": ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
    "description": "Backend cửa hàng"
  }'
```

**Node.js**

```ts
const { data: hook, error } = await hatang.webhooks.create({
  endpoint: "https://shop.example.vn/hatang/webhooks",
  events: ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
  description: "Backend cửa hàng",
});
// Lưu hook.signing_secret (whsec_…) trên server.
```

**Python**

```python
hook = requests.post("https://api.hatang.dev/webhooks", headers=HEADERS, json={
    "endpoint": "https://shop.example.vn/hatang/webhooks",
    "events": ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
    "description": "Backend cửa hàng",
}, timeout=10).json()
```

**PHP**

```php
$hook = json_decode($client->post('/webhooks', ['json' => [
    'endpoint' => 'https://shop.example.vn/hatang/webhooks',
    'events' => ['payment.succeeded', 'payment.partially_paid', 'payment.overpaid', 'transaction.received', 'invoice.issued'],
    'description' => 'Backend cửa hàng',
]])->getBody(), true);
```

Phản hồi (`201`) có `signing_secret` (`whsec_…`). Secret chỉ hiển thị khi tạo endpoint hoặc khi xoay secret, nên hãy lưu ngay trên server.

## Xác minh lần gửi

Lần gửi theo chuẩn [Standard Webhooks](https://www.standardwebhooks.com/) / Svix. Mỗi lần gửi có `svix-id`, `svix-timestamp` và `svix-signature` (cùng giá trị đó ở `webhook-id`, `webhook-timestamp` và `webhook-signature`).

**Node.js**

```ts
import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk";

app.post("/hatang/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await constructWebhookEvent(req.body, req.headers, process.env.HATANG_WEBHOOK_SECRET!); // whsec_…
    if (await alreadyHandled(req.get("svix-id"))) return res.sendStatus(200);
    await queue.add("hatang-webhook", event); // { type, created_at, data }
    res.sendStatus(200);
  } catch (err) {
    if (err instanceof HatangSignatureError) return res.sendStatus(401);
    throw err;
  }
});
```

Thư viện `svix` cũng dùng được: `new Webhook(secret).verify(rawBody, headers)`.

**Python**

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

SECRET = os.environ["HATANG_WEBHOOK_SECRET"]  # whsec_...
app = Flask(__name__)

@app.post("/hatang/webhooks")
def hatang_webhooks():
    try:
        event = construct_webhook_event(request.get_data(), request.headers, SECRET)  # { type, created_at, data }
    except HatangSignatureError:
        abort(401)
    # chống trùng theo header svix-id, rồi đưa việc vào queue
    return "", 200
```

**PHP**

```php
<?php
$secret = base64_decode(substr(getenv('HATANG_WEBHOOK_SECRET'), strlen('whsec_')));
$raw = file_get_contents('php://input');
$id = $_SERVER['HTTP_SVIX_ID'] ?? '';
$ts = $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '0';
if (abs(time() - (int) $ts) > 300) { http_response_code(401); exit; }

$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$raw", $secret, true));
$ok = false;
foreach (explode(' ', $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '') as $part) {
    [$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
    if ($version === 'v1' && hash_equals($expected, $sig)) { $ok = true; }
}
if (!$ok) { http_response_code(401); exit; }

$event = json_decode($raw, true); // ['type' => ..., 'created_at' => ..., 'data' => ...]
// chống trùng theo $id, rồi đưa việc vào queue
http_response_code(200);
```

**Tự xác minh:** base64-decode phần sau `whsec_` của secret, tính HMAC-SHA256 của `{svix-id}.{svix-timestamp}.{raw body}`, base64-encode kết quả, rồi so với từng `v1,<chữ ký>` trong `svix-signature` (cách nhau bởi khoảng trắng). Từ chối timestamp lệch quá 5 phút so với hiện tại.

> **Tip: Xoay secret**
>
> `POST /webhooks/{id}/rotate` (hoặc dashboard) tạo secret mới và trả về trong trường `secret`. Trong 24 giờ, lần gửi được ký bằng cả secret mới và secret cũ, nên `svix-signature` có hai chữ ký và chữ ký nào cũng xác minh được. Hãy triển khai secret mới trong khoảng thời gian đó.

## Cách gửi

| | |
|---|---|
| Body | `{ "type": "...", "created_at": "...", "data": { ... } }` |
| Thành công | Bất kỳ mã `2xx` nào trong 15 giây. Redirect không được theo |
| Thử lại | Sau 5 giây, 5 phút, 30 phút, 2 giờ, 5 giờ, 10 giờ và 10 giờ: 8 lần |
| Chống trùng | `svix-id` giữ nguyên qua mọi lần thử của một lần gửi |
| Nhật ký | `GET /webhooks/{id}/deliveries`, và **Webhooks** trên dashboard |

## Sự kiện Thanh toán

### Payment

| Sự kiện | Khi nào | `data` |
|---|---|---|
| `payment.created` | Tạo payment | Payment |
| `payment.succeeded` | Đã nhận đủ tiền | Payment, kèm `transaction` |
| `payment.partially_paid` | Nhận thiếu | Payment, kèm `transaction` |
| `payment.overpaid` | Nhận thừa | Payment, kèm `transaction` |
| `payment.expired` | Quá `expires_at` mà chưa có tiền | Payment |
| `payment.canceled` | Bạn đã hủy | Payment |

Khi payment được ghi nhận thủ công (`mark-paid`), `data` có `manual: { by, note, amount }` thay cho `transaction`.

### Giao dịch và tài khoản ngân hàng

| Sự kiện | Khi nào | `data` |
|---|---|---|
| `transaction.received` | Có giao dịch mới trên tài khoản đã liên kết, theo bộ lọc của tài khoản | Giao dịch, kèm `merchant_code` |
| `bank_connection.activated` | Liên kết tài khoản thành công | Kết nối ngân hàng |
| `bank_connection.revoked` | Tài khoản bị hủy liên kết | Kết nối ngân hàng |
| `bank_connection.needs_reauth` | Ngân hàng yêu cầu liên kết lại | Kết nối ngân hàng |

### Hóa đơn điện tử

| Sự kiện | Khi nào |
|---|---|
| `invoice.draft_created` / `invoice.draft_updated` / `invoice.draft_deleted` | Nháp thay đổi |
| `invoice.issued` | Đã ký, có số hóa đơn |
| `invoice.tax_accepted` / `invoice.tax_rejected` | Phản hồi của cơ quan thuế |
| `invoice.canceled` / `invoice.replaced` / `invoice.adjusted` | Hóa đơn bị hủy, thay thế hoặc điều chỉnh |
| `invoice.failed` | Nhà cung cấp từ chối một bước |
| `invoice.conflict` | Nháp bị sửa ở cả hai phía; giải quyết bằng `POST /einvoices/{id}/resolve` |

`data` là hóa đơn (`object: "einvoice"`), kèm `payment_id` hoặc `transaction_id` nếu có liên kết. Sự kiện phát sinh trên trang nhà cung cấp có `source: "provider"`.

## Ví dụ: payment thanh toán thành công

```json
{
  "type": "payment.succeeded",
  "created_at": "2026-09-19T09:34:12.950Z",
  "data": {
    "object": "payment",
    "id": "pay_01a0b904297873e8b3524a26fdca0ab5",
    "environment": "production",
    "status": "paid",
    "amount": 150000,
    "paid_amount": 150000,
    "code": "HTBF7RDGVQ",
    "metadata": { "order_id": "1001" },
    "invoice": null,
    "paid_at": "2026-09-19T09:34:12.940Z",
    "transaction": {
      "object": "transaction",
      "id": "txn_01a0b90429dc79c7a5689c7a0c485a01",
      "amount": 150000,
      "description": "CK HTBF7RDGVQ thanh toan",
      "reference": "FT1789810452930810",
      "payment_id": "pay_01a0b904297873e8b3524a26fdca0ab5",
      "match_status": "matched"
    }
  }
}
```

Object payment và giao dịch ở đây đã được rút gọn; webhook gửi đầy đủ mọi trường.

## Bước tiếp theo

- [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md): so sánh webhook với IPN và polling.
- [Nhận giao dịch không cần tạo đơn](https://hatang.dev/vi/docs/guides/payments/transactions.md): `transaction.received`.
- [Tham chiếu webhook](https://hatang.dev/vi/docs/reference/webhooks.md).

Source: https://hatang.dev/vi/docs/guides/payments/webhooks
