# Luồng tự động và webhook

Webhook của DANIX là một **luồng tự động**: một đồ thị các khối nối với nhau mà người dùng vẽ trong ứng dụng Tự động hoá của shop. Khối **Gửi HTTP** là khối gửi dữ liệu ra ngoài, và đó chính là webhook. Trang này nói về phía bên nhận: dữ liệu đến ra sao, cách kiểm chữ ký, thử lại và chống trùng. Cách các khối chạy (thứ tự, mục, Gộp, Gom, Khi lỗi) ở trang [Cách luồng chạy](/developers/flow-logic).

## Luồng trông thế nào

Một luồng gồm các khối **Kích hoạt**, **Nếu**, **Gộp**, **Gom** và **Gửi HTTP**, nối với nhau bằng dây và có thể rẽ nhánh. Luồng chạy **lần lượt**, theo cách của n8n: đi trọn một nhánh tới cuối rồi mới sang nhánh kế.

- Khối **Gửi HTTP** gửi **một lượt cho mỗi mục** nhận vào. Bật "Chạy một lần" thì chỉ mục đầu được gửi. Muốn gửi cả danh sách trong **một** lượt, đặt khối **Gom** phía trước.
- Bên nhận có thể trả JSON. Khối đứng sau dùng được mã trả lời, header và thân đó (xem bên dưới).
- Shop hết hạn gói thì luồng dừng và **không gửi bù** khi gia hạn.

## Một lượt gửi

Mỗi lượt là một request `POST` (hoặc `PUT`, `PATCH` theo cấu hình khối) tới địa chỉ HTTPS bạn khai, với thân JSON. Có hai kiểu thân: **chuẩn** (đúng [envelope sự kiện](/developers/events)) hoặc **mẫu JSON** do người dùng viết, chèn được biến từ sự kiện và từ các khối đứng trước.

Header của request:

| Header | Ý nghĩa |
| --- | --- |
| `Content-Type` | `application/json`. |
| `User-Agent` | `Danix-Webhooks/1.0`. |
| `webhook-id` | Mã của lượt gửi, dạng `msg_…`. Giữ nguyên qua mọi lần thử lại. Dùng để chống trùng. |
| `webhook-timestamp` | Thời điểm ký, số **giây** từ 1970. |
| `webhook-signature` | Chữ ký, dạng `v1,<base64>`. Có thể có nhiều chữ ký cách nhau bằng dấu cách khi bí mật đang được xoay. |

Cộng thêm các header người dùng khai ở khối (tối đa 10; không được trùng tên với các header ở bảng trên, `host`, `content-length`, `transfer-encoding`, `connection`, `upgrade`, `keep-alive` và `expect`).

Thời gian chờ tối đa cho một lượt là 15 giây. Bên nhận nên **trả lời nhanh** (2xx ngay khi đã nhận) rồi xử lý bất đồng bộ.

## Chữ ký (Standard Webhooks)

DANIX ký theo chuẩn [Standard Webhooks](https://www.standardwebhooks.com/), nên các thư viện kiểm chữ ký chính thức của chuẩn dùng được. Mỗi khối Gửi HTTP có **bí mật ký riêng**, dạng `whsec_<base64>`, hiện trong cài đặt của khối (cần quyền quản lý luồng; mỗi lần hiện đều được ghi vết).

Cách tính chữ ký:

1. Nội dung được ký là chuỗi `webhook-id`, dấu chấm, `webhook-timestamp`, dấu chấm, rồi **thân thô** của request.
2. Khoá là phần base64 sau `whsec_`, giải mã ra byte.
3. Chữ ký là HMAC-SHA256 của nội dung với khoá đó, mã hoá base64, ghi `v1,<base64>`.

Khi kiểm chữ ký, nhớ:

- Dùng **thân thô** đúng từng byte đã nhận. Parse JSON rồi serialize lại sẽ làm lệch chữ ký.
- So sánh bằng hàm so sánh thời gian hằng (`timingSafeEqual`, `hmac.compare_digest`, `hash_equals`), không so `==`.
- Kiểm `webhook-timestamp` nằm trong dung sai (mẫu dùng 5 phút) để chặn kẻ gửi lại một request cũ.
- Chấp nhận nếu **bất kỳ** chữ ký `v1` nào trong header khớp.

Xoay bí mật có thời gian chồng (mặc định 24 giờ): trong thời gian đó header mang hai chữ ký, một của bí mật mới và một của bí mật cũ, nên bên nhận đổi bí mật không bị gián đoạn. Lượt gửi "Chạy thử" của một khối **chưa lưu** chưa có bí mật nên được gửi **không có** header chữ ký; mã kiểm bên dưới sẽ từ chối nó, đúng như mong muốn. Chạy thử một bản nháp đã đổi địa chỉ nhận sang một gốc (scheme, tên máy, cổng) khác với bản đã lưu cũng được gửi không chữ ký, để bí mật mà địa chỉ cũ tin không đi tới địa chỉ mới. Lưu luồng rồi chạy thử lại để thử cả phần kiểm chữ ký.

### Node.js (đã chạy với vector kiểm thử)

Mẫu này được kiểm tự động với vector kiểm thử của Svix trong bộ test của chính DANIX.

```js
// Kiểm chữ ký webhook (Standard Webhooks) bằng Node.js, không cần thư viện ngoài.
// Với Express, đọc thân THÔ bằng express.raw({ type: 'application/json' }):
// ký trên đúng các byte đã nhận, không phải JSON đã parse rồi serialize lại.
import { createHmac, timingSafeEqual } from 'node:crypto'

/**
 * @param {string} secret      Bí mật ký của khối, dạng "whsec_...".
 * @param {Record<string, string | undefined>} headers  Header của request, tên chữ thường.
 * @param {string | Buffer} rawBody  Thân thô của request.
 * @param {object} [options]
 * @param {number} [options.toleranceSeconds]  Dung sai timestamp, mặc định 300 giây.
 * @param {number} [options.nowSeconds]        Giờ hiện tại theo giây, chỉ để kiểm thử.
 * @returns {unknown} Thân đã parse, chỉ khi chữ ký hợp lệ.
 */
export function verifyWebhook(secret, headers, rawBody, options = {}) {
  const toleranceSeconds = options.toleranceSeconds ?? 300
  const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000)

  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  const signatures = headers['webhook-signature']
  if (!id || !timestamp || !signatures) throw new Error('Thiếu header chữ ký')

  // Chặn gửi lại một request cũ: timestamp phải nằm trong dung sai.
  if (!Number.isFinite(Number(timestamp)) || Math.abs(nowSeconds - Number(timestamp)) > toleranceSeconds) {
    throw new Error('Timestamp lệch quá xa')
  }

  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8')
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const signedContent = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), body])
  const expected = createHmac('sha256', key).update(signedContent).digest()

  // Header có thể mang nhiều chữ ký cách nhau bằng dấu cách (khi đang xoay bí mật).
  for (const part of signatures.split(' ')) {
    const [version, value] = part.split(',')
    const given = version === 'v1' && value ? Buffer.from(value, 'base64') : null
    if (given && given.length === expected.length && timingSafeEqual(given, expected)) {
      return JSON.parse(body.toString('utf8'))
    }
  }

  throw new Error('Chữ ký không hợp lệ')
}
```

Dùng với Express:

```js
import express from 'express'
import { verifyWebhook } from './verify-webhook.mjs'

const app = express()

// express.raw giữ thân thô; không dùng express.json() cho route này.
app.post('/hooks/danix', express.raw({ type: 'application/json' }), (req, res) => {
  let event
  try {
    event = verifyWebhook(process.env.DANIX_WEBHOOK_SECRET, req.headers, req.body)
  } catch {
    return res.sendStatus(400)
  }

  // Xử lý bất đồng bộ; trả 2xx ngay khi đã nhận.
  res.sendStatus(204)
})
```

### Python (mẫu tham khảo)

Viết theo cùng thuật toán với mẫu Node.js; chưa nằm trong bộ test tự động của DANIX.

```python
# Kiểm chữ ký webhook (Standard Webhooks) bằng Python, chỉ dùng thư viện chuẩn.
# Mẫu tham khảo viết theo cùng thuật toán với mẫu Node.js.
import base64
import hashlib
import hmac
import json
import time


def verify_webhook(secret, headers, raw_body, tolerance_seconds=300, now=None):
    """Trả về thân đã parse nếu chữ ký hợp lệ, ngược lại ném ValueError.

    secret    -- bí mật ký của khối, dạng "whsec_..."
    headers   -- dict header của request, tên chữ thường
    raw_body  -- thân THÔ của request (bytes), không phải JSON đã parse
    """
    webhook_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not webhook_id or not timestamp or not signatures:
        raise ValueError("Thiếu header chữ ký")

    # Chặn gửi lại một request cũ: timestamp phải nằm trong dung sai.
    current = time.time() if now is None else now
    try:
        sent_at = int(timestamp)
    except ValueError:
        raise ValueError("Timestamp không hợp lệ")
    if abs(current - sent_at) > tolerance_seconds:
        raise ValueError("Timestamp lệch quá xa")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{webhook_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(key, signed_content, hashlib.sha256).digest()

    # Header có thể mang nhiều chữ ký cách nhau bằng dấu cách (khi đang xoay bí mật).
    for part in signatures.split(" "):
        version, _, value = part.partition(",")
        if version != "v1" or not value:
            continue
        if hmac.compare_digest(base64.b64decode(value), expected):
            return json.loads(raw_body)

    raise ValueError("Chữ ký không hợp lệ")
```

### PHP (mẫu tham khảo)

Viết theo cùng thuật toán với mẫu Node.js; chưa nằm trong bộ test tự động của DANIX.

```php
<?php
// Kiểm chữ ký webhook (Standard Webhooks) bằng PHP, không cần thư viện ngoài.
// Mẫu tham khảo viết theo cùng thuật toán với mẫu Node.js.

/**
 * Trả về thân đã parse nếu chữ ký hợp lệ, ngược lại ném Exception.
 *
 * @param string $secret   Bí mật ký của khối, dạng "whsec_...".
 * @param array  $headers  Header của request, tên chữ thường.
 * @param string $rawBody  Thân THÔ của request, ví dụ file_get_contents('php://input').
 */
function verifyWebhook(string $secret, array $headers, string $rawBody, int $toleranceSeconds = 300): array
{
    $id = $headers['webhook-id'] ?? null;
    $timestamp = $headers['webhook-timestamp'] ?? null;
    $signatures = $headers['webhook-signature'] ?? null;
    if (!$id || !$timestamp || !$signatures) {
        throw new Exception('Thiếu header chữ ký');
    }

    // Chặn gửi lại một request cũ: timestamp phải nằm trong dung sai.
    if (!ctype_digit((string) $timestamp) || abs(time() - (int) $timestamp) > $toleranceSeconds) {
        throw new Exception('Timestamp lệch quá xa');
    }

    $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
    $expected = hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true);

    // Header có thể mang nhiều chữ ký cách nhau bằng dấu cách (khi đang xoay bí mật).
    foreach (explode(' ', $signatures) as $part) {
        [$version, $value] = array_pad(explode(',', $part, 2), 2, null);
        if ($version !== 'v1' || !$value) {
            continue;
        }
        $given = base64_decode($value, true);
        if ($given !== false && hash_equals($expected, $given)) {
            return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
        }
    }

    throw new Exception('Chữ ký không hợp lệ');
}
```

## Phản hồi của bên nhận và thử lại

| Bên nhận trả | DANIX làm gì |
| --- | --- |
| Mọi mã **2xx** | Thành công. |
| 3xx | Coi là lỗi (không đi theo chuyển hướng). Hãy khai đúng địa chỉ cuối. |
| 410 | Tạm dừng luồng ngay: bên nhận nói địa chỉ này đã bỏ. |
| 429 hoặc 503 kèm `Retry-After` | Chờ theo `Retry-After`, tối đa 1 giờ, rồi thử lại. |
| Các mã lỗi khác, hết giờ, lỗi mạng | Thử lại theo lịch bên dưới. |

Lịch thử lại có **8 lượt gửi trong khoảng 44 giờ**: lượt đầu, rồi cách 5 giây, 5 phút, 30 phút, 2 giờ, 6 giờ, 12 giờ, 24 giờ, mỗi khoảng có độ lệch ngẫu nhiên khoảng 20% để tránh dồn nhịp. Có thể tắt thử lại trong cài đặt khối. Trong lúc một lượt chờ thử lại, **lượt chạy dừng chờ ở khối ấy**: các mục và nhánh phía sau đợi, đúng thứ tự.

Không thử lại những lỗi mà thử lại cũng không khác đi: địa chỉ bị chặn (địa chỉ nội bộ, địa chỉ của hạ tầng DANIX), URL hỏng, nội dung gửi quá 256 KB, lỗi mẫu JSON.

Luồng **tự tạm dừng** khi một khối Gửi HTTP không có lượt thành công nào trong hơn 72 giờ và đã có ít nhất 10 lỗi liên tiếp **do bên nhận**; lỗi phía DANIX không tính. Luồng bị tắt, việc đang chờ bị huỷ, hành động này được ghi vào nhật ký của shop, và shop bật lại luồng bằng tay khi bên nhận ổn.

## Chống trùng

DANIX giao theo kiểu **ít nhất một lần**: cùng một lượt có thể tới hơn một lần (ví dụ bên nhận đã xử lý xong nhưng phản hồi bị mất, nên lượt đó được thử lại). Hãy lưu `webhook-id` đã xử lý và bỏ qua lượt trùng. `webhook-id` được suy từ lần gọi khối và vị trí của mục, nên **không đổi** qua mọi lần thử lại và qua nút "Gửi lại với bản cũ". Riêng "Gửi lại với bản luồng đang lưu" là tin mới nên mang `webhook-id` mới. Thứ tự các lượt **không được đảm bảo**.

Phân biệt hai mã: `id` trong envelope là mã của **sự kiện** (cùng giá trị ở mọi luồng nhận cùng một sự kiện, dạng `evt_…`), còn `webhook-id` là mã của **lượt gửi** (dạng `msg_…`). Cả hai là chuỗi mờ đục, không phải UUID: lưu vào cột chuỗi.

## Dùng phản hồi của bên nhận trong khối sau

Nếu bên nhận trả JSON, các khối đứng sau khối Gửi HTTP dùng được mã trả lời (`status`), header và thân (`body`) của nó, ví dụ biến `#{NODE(http_1).item.body.id}`. Thân chỉ được lưu **tối đa 64 KB**; thân dài hơn bị cắt. Nếu thân là một mảng thì mỗi phần tử trở thành một mục riêng. Vì vậy **bên nhận đừng trả bí mật trong thân**: phản hồi được lưu để hiện trong nhật ký chạy. Header `set-cookie` luôn bị bỏ.

## Nhật ký và gửi lại

Mỗi lượt chạy có nhật ký theo từng khối trong 7 ngày: trạng thái, số mục, từng lần gửi. Lần gửi lỗi, và mọi lần gửi của lượt "Chạy thử", lưu thêm tối đa 16 KB đầu của thân đã gửi; giá trị header không bao giờ được lưu. Khối lỗi có nút **Gửi lại** (xem [Cách luồng chạy](/developers/flow-logic#gui-lai)). Sau 7 ngày nhật ký bị xoá.

## Dữ liệu khách và yêu cầu xoá

Khi khách chat yêu cầu xoá dữ liệu qua Facebook, DANIX xoá nhật ký chạy có dữ liệu của khách ấy. Bản đã gửi tới hệ thống của bạn thì **bạn tự xoá** theo nghĩa vụ của mình.

## Địa chỉ nhận và an toàn

- Địa chỉ phải là **HTTPS**. DANIX từ chối địa chỉ trỏ vào mạng nội bộ và vào hạ tầng của chính DANIX.
- Địa chỉ và header **không nhận biến**: dữ liệu từ bên ngoài không bao giờ quyết được nơi dữ liệu được gửi tới.
- Đừng đặt khoá hay token trong địa chỉ nhận nếu tránh được. Người chỉ có quyền **xem** luồng thấy địa chỉ đã che (chỉ còn phần gốc), nhưng khoá vẫn nằm trong cấu hình luồng. Dùng header bí mật hoặc chữ ký thay cho khoá trong URL.
- Header khai là "bí mật" chỉ được gửi tới đúng địa chỉ mà nó được khai cùng. Đổi địa chỉ thì header bí mật bị xoá và phải nhập lại.

## IP gửi đi

Các lượt gửi của luồng tới từ địa chỉ IP **103.179.173.155**. Nếu bên nhận chặn theo IP, hãy cho phép địa chỉ này. Địa chỉ có thể đổi khi DANIX chuyển máy chủ; thay đổi sẽ được báo trước, nhưng không nên coi đây là cách xác thực duy nhất: **luôn kiểm chữ ký**.
