> ## Documentation Index
> Fetch the complete documentation index at: https://docs.econtractid.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook

> Nhận thông báo khi hồ sơ hoàn tất — payload, xác thực chữ ký, retry và idempotency

Khi hồ sơ chuyển sang trạng thái quan trọng, hệ thống gửi `POST` tới URL webhook của bạn. Webhook có chữ ký HMAC, cơ chế retry và khoá idempotency.

<Warning>
  Partner không tự đăng ký webhook qua API. Gửi đội ngũ econtractid URL endpoint HTTPS + danh sách event; quản trị viên tạo subscription và cấp **HMAC secret** (chỉ hiện 1 lần). Đổi URL hoặc event sau này cũng qua quản trị viên.
</Warning>

***

## Luồng gửi

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant Y as Hệ thống của bạn
    participant S as econtractid

    Y->>S: POST /integration/documents
    S-->>Y: 200 (status PENDING_SIGN)
    Note over S: người ký hoàn tất trên giao diện
    S->>Y: POST webhook URL (document.completed)
    alt Trả 2xx
        Y-->>S: 200 OK
        Note over S: Gửi thành công
    else Lỗi hoặc timeout
        S->>S: retry 1s, 10s, 1m, 10m, 1h
        S->>Y: POST lại (cùng X-Webhook-Id)
    end
```

***

## Headers

| Header                | Giá trị                 | Mô tả                                                                           |
| --------------------- | ----------------------- | ------------------------------------------------------------------------------- |
| `X-Webhook-Event`     | `document.completed`    | Loại event                                                                      |
| `X-Webhook-Signature` | `sha256=<hex>`          | HMAC-SHA256 của **raw body** với webhook secret                                 |
| `X-Webhook-Id`        | `evt_<uuid>`            | ID event duy nhất, dùng để chống xử lý trùng. Event test có tiền tố `evt_test_` |
| `User-Agent`          | `eContract-Webhook/1.0` | —                                                                               |

Mọi payload dùng chung envelope:

```json theme={null}
{
  "id": "evt_550e8400-e29b-41d4-a716-446655440000",
  "type": "document.completed",
  "createdAt": "2026-05-16T17:58:12.345Z",
  "data": { }
}
```

<Note>
  Webhook payload **không** có envelope `{statusCode, data, timestamp}` như response REST — gửi raw object.
</Note>

***

## Danh sách event

### Event tích hợp (`document.*`)

Dùng để đồng bộ trạng thái. Bật sẵn mặc định.

| Event                | Kích hoạt khi                       |
| -------------------- | ----------------------------------- |
| `document.completed` | Tất cả người ký đã ký xong          |
| `document.rejected`  | Một người ký từ chối                |
| `webhook.test`       | Quản trị viên bấm test từ giao diện |

### Event thông báo (`notification.*`)

Dùng để **uỷ quyền gửi email**: thay vì econtractid gửi email thương hiệu của mình, hệ thống bắn webhook kèm danh sách người nhận và ngữ cảnh công việc, để bạn tự render và gửi email theo thương hiệu của mình.

| Event                                 | Kích hoạt khi                 | Người nhận                                 |
| ------------------------------------- | ----------------------------- | ------------------------------------------ |
| `notification.task_assigned_internal` | Nhân sự nội bộ được giao việc | Nội bộ                                     |
| `notification.task_assigned_external` | Người ký bên ngoài được mời   | Bên ngoài (có thể kèm thông tin đăng nhập) |
| `notification.task_rejected`          | Có người từ chối ký           | Hỗn hợp (người tạo + người tham gia)       |
| `notification.process_finished`       | Toàn bộ quy trình hoàn tất    | Hỗn hợp                                    |

<Note>
  Đây là **4 email thông báo duy nhất** hệ thống thực sự gửi. Các event `notification.*` là **opt-in** — phải đăng ký subscription mới nhận được.
</Note>

**Dự kiến bổ sung:** `document.signer_signed` (từng người ký xong), `document.expired` (quá hạn).

***

## Payload — `document.completed`

```json theme={null}
{
  "id": "evt_550e8400-e29b-41d4-a716-446655440000",
  "type": "document.completed",
  "createdAt": "2026-05-16T17:58:12.345Z",
  "data": {
    "documentId": "HD-2026-001",
    "documentUuid": "abc-uuid-folder",
    "documentTitle": "Hợp đồng dịch vụ — Nguyễn Văn A",
    "transactionId": "PI000042",
    "status": "COMPLETED",
    "completedAt": "2026-05-16T17:58:10.000Z",
    "files": [
      {
        "type": "signed_document",
        "url": "https://minio.../abc_signed.pdf?X-Amz-Signature=...",
        "expiresAt": "2026-05-23T17:58:10.000Z",
        "name": "Hop_dong_lao_dong_signed.pdf",
        "mimeType": "application/pdf",
        "sizeBytes": 245678,
        "ecmNodeUuid": "550e8400-e29b-41d4-a716-446655440001",
        "ecmClassCode": "CONTRACT_LABOR"
      }
    ],
    "signedCount": 2,
    "totalSigners": 2,
    "signers": [
      { "stepName": "Người lao động ký", "stepCode": "STEP_SIGN_HR", "email": "a@x.com", "identityNumber": "079...", "status": "SIGNED", "signedAt": "2026-05-16T17:55:00.000Z" },
      { "stepName": "Đại diện đối tác ký", "stepCode": "STEP_SIGN_PARTNER", "email": "b@x.com", "taxCode": "0312345678", "status": "SIGNED", "signedAt": "2026-05-16T17:57:00.000Z" }
    ],
    "metadata": { "tenantCode": "T001", "contractValue": 100000 }
  }
}
```

`files[]` được thiết kế **tương thích tiến**: bạn có thể bỏ qua các `type` chưa nhận diện, cho phép hệ thống thêm loại file mới mà không phá vỡ tích hợp cũ. `ecmNodeUuid` chính là `fileUuid` dùng cho [endpoint tải file](/api-reference/endpoint/ho-so).

<Warning>
  `signers[]` và `totalSigners` **chỉ tính người ký bên ngoài** — bước của nhân sự nội bộ và bước theo vai trò bị loại khỏi payload. Vì vậy `totalSigners` có thể nhỏ hơn `requiredCount` của [endpoint tra cứu](/api-reference/endpoint/ho-so); đừng đối soát trực tiếp hai con số này.
</Warning>

<Warning>
  `files[].url` là presigned URL — lấy thẳng từ JSON parser rồi GET, đừng đọc từ log thô hay re-marshal (Go mặc định escape `&`; nếu buộc phải, dùng `encoder.SetEscapeHTML(false)`). Dấu hiệu sai: URL có `%5Cu0026`, kho lưu trữ trả `AccessDenied` dù chưa hết hạn.
</Warning>

***

## Payload — `document.rejected`

```json theme={null}
{
  "id": "evt_660e8400-e29b-41d4-a716-446655440000",
  "type": "document.rejected",
  "createdAt": "2026-05-16T18:10:00.000Z",
  "data": {
    "documentId": "HD-2026-002",
    "documentUuid": "def-uuid-folder",
    "documentTitle": "Hợp đồng dịch vụ — Trần Văn B",
    "transactionId": "PI000043",
    "status": "REJECTED",
    "rejectedAt": "2026-05-16T18:10:00.000Z",
    "rejectedBy": {
      "fullName": "Trần Văn B",
      "email": "b@x.com",
      "phone": "+84909876543",
      "identityNumber": "079209876543"
    },
    "reason": "Sai thông tin trên hợp đồng",
    "files": [
      {
        "type": "input_document",
        "url": "https://minio.../template.pdf?X-Amz-Signature=...",
        "expiresAt": "2026-05-23T18:10:00.000Z",
        "name": "contract_template.pdf",
        "mimeType": "application/pdf",
        "sizeBytes": 198234,
        "ecmNodeUuid": "660e8400-e29b-0001-a716-446655440000",
        "ecmClassCode": "CONTRACT_LABOR"
      }
    ],
    "totalSigners": 2,
    "signers": [
      { "stepName": "Người lao động ký", "stepCode": "STEP_SIGN_HR", "email": "a@x.com", "status": "SIGNED", "signedAt": "2026-05-16T18:05:00.000Z" },
      { "stepName": "Đại diện đối tác ký", "stepCode": "STEP_SIGN_PARTNER", "email": "b@x.com", "status": "REJECTED", "signedAt": null }
    ],
    "metadata": { "tenantCode": "T001" }
  }
}
```

`document.rejected` vẫn trả về `files[]` và `signers[]` đầy đủ như `document.completed` — chỉ khác là chưa có bản ký hoàn chỉnh, và có thêm `rejectedBy` + `reason`.

***

## Payload — `webhook.test`

Event kiểm thử kết nối, bắn khi quản trị viên bấm **Test** hoặc khi bạn gọi API ở mục dưới. Dùng để nghiệm thu endpoint: URL, TLS, secret, và bạn có trả `2xx` không.

```json theme={null}
{
  "id": "evt_test_770e8400-e29b-41d4-a716-446655440000",
  "type": "webhook.test",
  "createdAt": "2026-05-16T10:00:00.000Z",
  "data": {
    "message": "This is a test ping from eContract webhook system.",
    "subscriptionId": "550e8400-e29b-41d4-a716-446655440000",
    "triggeredBy": "super_admin",
    "metadata": {}
  }
}
```

### Khác biệt so với event thật

|                          | `webhook.test`      | `document.*` / `notification.*`        |
| ------------------------ | ------------------- | -------------------------------------- |
| Đích đến                 | Đúng 1 subscription | Fanout tới mọi subscription khớp event |
| Lọc theo danh sách event | Bỏ qua              | Có lọc                                 |
| Retry                    | Không — gửi 1 lần   | 6 lần theo lịch retry                  |
| Redirect (`301/302`)     | Không đi theo       | Không đi theo                          |
| Prefix event id          | `evt_test_`         | `evt_`                                 |

### Tự bắn ping qua API

Hệ thống gửi `webhook.test` tới webhook đã đăng ký của tổ chức bạn — giống hệt khi quản trị viên bấm Test.

```bash theme={null}
curl -X POST 'https://api.econtractid.com/api/integration/webhooks/test' \
  -H 'X-API-Key: econtract_a1b2c3d4...'
```

Dùng **API Key theo tổ chức** (đã gắn người dùng). Key toàn cục dành cho `partner-login`, gọi vào đây sẽ nhận `401`.

```json theme={null}
{
  "statusCode": 200,
  "data": {
    "total": 1,
    "results": [
      {
        "subscriptionId": "550e8400-e29b-41d4-a716-446655440000",
        "url": "https://partner.example.com/webhooks/econtract",
        "httpStatus": 200,
        "latencyMs": 143,
        "requestPayload": { },
        "requestHeaders": { },
        "responseBody": "ok",
        "success": true
      }
    ]
  }
}
```

`requestPayload` / `requestHeaders` là đúng những gì đã gửi, dùng để đối chiếu khi chữ ký lệch. `httpStatus: 0` = không kết nối được hoặc quá 10 giây.

| HTTP  | Nguyên nhân                                               |
| ----- | --------------------------------------------------------- |
| `403` | Gọi bằng access token — endpoint này chỉ nhận `X-API-Key` |
| `400` | API Key toàn cục, không gắn tổ chức cụ thể                |
| `404` | Tổ chức chưa có subscription nào đang hoạt động           |

### Request thực tế gửi đi

```http theme={null}
POST /your-webhook-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: eContract-Webhook/1.0
X-Webhook-Event: webhook.test
X-Webhook-Id: evt_test_770e8400-e29b-41d4-a716-446655440000
X-Webhook-Signature: sha256=854bba9ebf762164a8b3560aeaeb6c6969f7d98c10b613252d6ecd93ecbc4de4

{"createdAt":"2026-05-16T10:00:00.000Z","data":{"message":"This is a test ping from eContract webhook system.","metadata":{},"subscriptionId":"550e8400-e29b-41d4-a716-446655440000","triggeredBy":"super_admin"},"id":"evt_test_770e8400-e29b-41d4-a716-446655440000","type":"webhook.test"}
```

Body trên dây **nén và sắp xếp khoá theo alphabet**, khác thứ tự khối JSON minh hoạ ở trên. Luôn HMAC trên raw body nhận được, đừng parse rồi serialize lại.

### Tự kiểm thử bằng curl

Không cần chờ quản trị viên bấm Test: tự bắn một request **giống hệt** vào endpoint của bạn. Copy nguyên khối dưới đây, sửa đúng 2 dòng đầu, dán vào terminal (bash hoặc zsh; macOS và Linux đều chạy).

```bash theme={null}
# ─── SỬA 2 DÒNG NÀY ───────────────────────────────────────────────────────────
WEBHOOK_SECRET='dan_secret_quan_tri_vien_da_cap'
TARGET_URL='https://your-domain.com/webhooks/econtract'   # endpoint webhook CỦA BẠN
# ──────────────────────────────────────────────────────────────────────────────

UUID="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)"
EVENT_ID="evt_test_$(printf '%s' "$UUID" | tr 'A-Z' 'a-z')"
CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"

# Khoá sắp xếp alphabet, không khoảng trắng — đúng chuỗi byte hệ thống gửi và ký
BODY="{\"createdAt\":\"$CREATED_AT\",\"data\":{\"message\":\"This is a test ping from eContract webhook system.\",\"metadata\":{},\"subscriptionId\":\"550e8400-e29b-41d4-a716-446655440000\",\"triggeredBy\":\"super_admin\"},\"id\":\"$EVENT_ID\",\"type\":\"webhook.test\"}"

# Ký trên đúng $BODY, không phải trên bản JSON đã format lại
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -r | cut -d' ' -f1)"

curl -i -s -X POST "$TARGET_URL" \
  -H 'Content-Type: application/json' \
  -H 'User-Agent: eContract-Webhook/1.0' \
  -H 'X-Webhook-Event: webhook.test' \
  -H "X-Webhook-Id: $EVENT_ID" \
  -H "X-Webhook-Signature: $SIG" \
  --max-time 10 \
  --data-raw "$BODY"
```

Đạt khi dòng đầu output là `HTTP/1.1 2xx`. `TARGET_URL` là endpoint **của chính bạn**, không phải endpoint của econtractid.

### Chưa dựng xong endpoint?

<CodeGroup>
  ```bash Receiver chạy tại máy bạn theme={null}
  # Cửa sổ 1 — nhận request, tự xác thực HMAC, in kết quả. Dừng bằng Ctrl+C
  WH_SECRET='dan_secret_quan_tri_vien_da_cap' python3 - <<'PY'
  import hmac, hashlib, os
  from http.server import BaseHTTPRequestHandler, HTTPServer
  SECRET = os.environ['WH_SECRET'].encode()
  class H(BaseHTTPRequestHandler):
      def do_POST(self):
          raw = self.rfile.read(int(self.headers.get('Content-Length', 0)))
          expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
          ok = hmac.compare_digest(self.headers.get('X-Webhook-Signature', ''), expected)
          print(f"event={self.headers.get('X-Webhook-Event')} "
                f"id={self.headers.get('X-Webhook-Id')} chu_ky_hop_le={ok}", flush=True)
          self.send_response(200 if ok else 401); self.end_headers(); self.wfile.write(b'ok')
      def log_message(self, *a): pass
  HTTPServer(('127.0.0.1', 8899), H).serve_forever()
  PY

  # Cửa sổ 2 — dùng URL này cho khối curl ở trên
  TARGET_URL='http://127.0.0.1:8899/webhooks/econtract'
  ```

  ```bash Endpoint sandbox của econtractid theme={null}
  TARGET_URL='https://api.econtractid.com/api/webhook-test/thu-nghiem'
  ```
</CodeGroup>

Receiver local xác thực chữ ký thật — in `chu_ky_hop_le=True` là HMAC hai bên khớp. Sandbox `/api/webhook-test/{tag}` chỉ ghi lại request và luôn trả `201`.

<Warning>
  Sandbox chỉ dùng khi onboard: không xác thực chữ ký, ghi nguyên headers + body ra đĩa máy chủ. Đừng gửi dữ liệu thật, đừng trỏ subscription production vào đó.
</Warning>

### Vector kiểm chứng chữ ký

Kiểm tra hàm HMAC của bạn không cần gọi mạng. Body mẫu ở trên, secret `whsec_demo_secret` → chữ ký bắt buộc phải ra:

```
sha256=854bba9ebf762164a8b3560aeaeb6c6969f7d98c10b613252d6ecd93ecbc4de4
```

```bash theme={null}
printf '%s' '{"createdAt":"2026-05-16T10:00:00.000Z","data":{"message":"This is a test ping from eContract webhook system.","metadata":{},"subscriptionId":"550e8400-e29b-41d4-a716-446655440000","triggeredBy":"super_admin"},"id":"evt_test_770e8400-e29b-41d4-a716-446655440000","type":"webhook.test"}' \
  | openssl dgst -sha256 -hmac 'whsec_demo_secret' -r
```

Ra khác nghĩa là code của bạn thêm/bớt byte — thường do trailing newline hoặc đã parse rồi serialize lại body.

### Cách phản hồi

Vẫn xác thực HMAC như event thật, rồi trả `200` mà không chạy nghiệp vụ.

### Đọc kết quả

| Kết quả                      | Nguyên nhân thường gặp                                                                     |
| ---------------------------- | ------------------------------------------------------------------------------------------ |
| `httpStatus: 0`              | Sai domain, DNS chưa trỏ, firewall chặn, TLS không hợp lệ, hoặc quá 10 giây                |
| `301` / `302`                | URL đăng ký đang redirect — hệ thống không đi theo, đăng ký thẳng URL đích                 |
| `401` / `403`                | Endpoint có xác thực riêng, hoặc chữ ký lệch do sai secret / parse lại body trước khi HMAC |
| `404` / `405`                | Sai đường dẫn, hoặc route không nhận `POST`                                                |
| `2xx` mà latency \~10 000 ms | Đang xử lý đồng bộ trong request — trả `2xx` trước, xử lý nền sau                          |

***

## Payload — `notification.*`

Cùng envelope `{ id, type, createdAt, data }`. Phần `data` theo nguyên tắc codes-first — không có ID số nội bộ.

| Trường                                           | Kiểu             | Mô tả                                         |
| ------------------------------------------------ | ---------------- | --------------------------------------------- |
| `transactionId`                                  | `string \| null` | Mã tiến trình                                 |
| `documentId`                                     | `string \| null` | Mã hồ sơ bạn cấp                              |
| `documentUuid`                                   | `string \| null` | UUID hồ sơ                                    |
| `documentTitle`                                  | `string \| null` | Tên hồ sơ                                     |
| `totalSteps`                                     | `number`         | Tổng số bước                                  |
| `step`                                           | `object \| null` | Bước liên quan; `null` với `process_finished` |
| `step.stepCode` / `step.stepName` / `step.order` |                  | Mã, tên, thứ tự bước (bắt đầu từ 1)           |
| `recipients[]`                                   | `array`          | Danh sách người nhận thông báo                |
| `signUrl`                                        | `string \| null` | Link ký                                       |
| `expireAt`                                       | `string \| null` | Hạn của link ký                               |
| `prevActorName`                                  | `string \| null` | Người xử lý bước trước                        |
| `completeDate`                                   | `string \| null` | Thời điểm hoàn tất                            |
| `note`                                           | `string \| null` | Lý do từ chối (chỉ có ở `task_rejected`)      |
| `metadata`                                       | `object`         | Echo `metadata` bạn gửi                       |

### `recipients[]`

| Trường                     | Kiểu                       | Mô tả                                                                        |
| -------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
| `type`                     | `'internal' \| 'external'` | Phân biệt nhân sự nội bộ và người ký bên ngoài                               |
| `name` / `email` / `phone` | `string \| null`           | Thông tin người nhận                                                         |
| `credentials`              | `object \| null`           | Thông tin đăng nhập — **chỉ có với người nhận external**                     |
| `credentials.userName`     | `string`                   | Tên đăng nhập                                                                |
| `credentials.password`     | `string \| null`           | Mật khẩu **dạng văn bản thuần**, hoặc `null` nếu tài khoản không có mật khẩu |

Ba biến thể của `credentials` (external): `null` (không có tài khoản) · `{ userName, password: null }` (chỉ username) · `{ userName, password }` (có mật khẩu). Người nhận nội bộ **luôn** có `credentials: null`.

`email` và `phone` lấy theo đúng thông tin bước ký khai báo. Bước mời người ký **bằng số điện thoại** sẽ có `email: null` và `phone` có giá trị — dùng `phone` để gửi SMS. Luôn kiểm tra trường nào khác `null` rồi mới chọn kênh gửi, đừng mặc định là có `email`.

### Ví dụ — `notification.task_assigned_external`

```json theme={null}
{
  "id": "evt_770e8400-e29b-41d4-a716-446655440001",
  "type": "notification.task_assigned_external",
  "createdAt": "2026-06-18T09:00:00.000Z",
  "data": {
    "transactionId": "PI000042",
    "documentId": "HD-2026-001",
    "documentUuid": "abc-uuid-folder",
    "documentTitle": "Hợp đồng lao động",
    "totalSteps": 2,
    "step": { "stepCode": "STEP_SIGN_PARTNER", "stepName": "Đại diện đối tác ký", "order": 1 },
    "recipients": [
      {
        "type": "external",
        "name": "Nguyễn Văn A",
        "email": "signer@partner.com",
        "phone": "+84-901-234-567",
        "credentials": { "userName": "signer_task_12345", "password": "..." }
      }
    ],
    "signUrl": "https://app.econtractid.com/tasks?id=12345",
    "expireAt": "2026-06-25T09:00:00.000Z",
    "prevActorName": null,
    "completeDate": null,
    "note": null,
    "metadata": { "tenantCode": "T001" }
  }
}
```

Event này bắn cho **mọi** trường hợp giao bước ra ngoài — mời bằng email, bằng số điện thoại, hoặc qua hồ sơ đối tác. Với người ký mời bằng số điện thoại, payload có đủ ba thứ để gửi SMS: `phone`, `credentials`, `signUrl`:

```json theme={null}
"recipients": [
  {
    "type": "external",
    "name": "Nguyễn Văn A",
    "email": null,
    "phone": "0985573168",
    "credentials": { "userName": "0985573168", "password": "TempPass@2026" }
  }
]
```

<Warning>
  **Event này truyền mật khẩu dạng văn bản thuần.** Endpoint phải dùng HTTPS, không để công khai, xác thực chữ ký trước khi parse, và không ghi log `recipients` dạng thô. Gửi hỏng thì người ký không nhận được thông tin đăng nhập — không có cơ chế bù.
</Warning>

### Ví dụ — `notification.task_assigned_internal`

Nhân sự nội bộ được giao bước xử lý. Người nhận **luôn** có `credentials: null`, và econtractid **không** ngừng gửi email nội bộ dù tổ chức có uỷ quyền event này.

```json theme={null}
{
  "id": "evt_660e8400-e29b-41d4-a716-446655440000",
  "type": "notification.task_assigned_internal",
  "createdAt": "2026-06-18T08:30:00.000Z",
  "data": {
    "transactionId": "PI000042",
    "documentId": "HD-2026-001",
    "documentUuid": "abc-uuid-folder",
    "documentTitle": "Hợp đồng lao động",
    "totalSteps": 2,
    "step": { "stepCode": "STEP_REVIEW", "stepName": "Trưởng phòng duyệt", "order": 1 },
    "recipients": [
      {
        "type": "internal",
        "name": "Trần Văn B",
        "email": "manager@partner.com",
        "phone": null,
        "credentials": null
      }
    ],
    "signUrl": "https://app.econtractid.com/tasks?id=12344",
    "expireAt": "2026-06-25T08:30:00.000Z",
    "prevActorName": "Lê Thị C",
    "completeDate": null,
    "note": null,
    "metadata": { "tenantCode": "T001" }
  }
}
```

### Ví dụ — `notification.task_rejected`

Một người ký từ chối. `note` là lý do từ chối, `step` là bước bị từ chối, `recipients` gồm cả người tạo lẫn người tham gia.

```json theme={null}
{
  "id": "evt_880e8400-e29b-41d4-a716-446655440002",
  "type": "notification.task_rejected",
  "createdAt": "2026-06-18T10:30:00.000Z",
  "data": {
    "transactionId": "PI000042",
    "documentId": "HD-2026-001",
    "documentUuid": "abc-uuid-folder",
    "documentTitle": "Hợp đồng lao động",
    "totalSteps": 2,
    "step": { "stepCode": "STEP_SIGN_PARTNER", "stepName": "Đại diện đối tác ký", "order": 1 },
    "recipients": [
      { "type": "internal", "name": "Trần Văn B", "email": "manager@partner.com", "phone": null, "credentials": null },
      { "type": "external", "name": "Nguyễn Văn A", "email": "signer@partner.com", "phone": "0901234567", "credentials": null }
    ],
    "signUrl": "https://app.econtractid.com/tasks?id=12345",
    "expireAt": null,
    "prevActorName": "Nguyễn Văn A",
    "completeDate": "2026-06-18T10:29:00.000Z",
    "note": "Sai thông tin trên hợp đồng",
    "metadata": { "tenantCode": "T001" }
  }
}
```

### Ví dụ — `notification.process_finished`

Toàn bộ tiến trình hoàn tất. `step` là `null` (không còn bước nào), `signUrl` cũng `null`.

```json theme={null}
{
  "id": "evt_990e8400-e29b-41d4-a716-446655440003",
  "type": "notification.process_finished",
  "createdAt": "2026-06-18T11:45:00.000Z",
  "data": {
    "transactionId": "PI000042",
    "documentId": "HD-2026-001",
    "documentUuid": "abc-uuid-folder",
    "documentTitle": "Hợp đồng lao động",
    "totalSteps": 2,
    "step": null,
    "recipients": [
      { "type": "internal", "name": "Trần Văn B", "email": "manager@partner.com", "phone": null, "credentials": null },
      { "type": "external", "name": "Nguyễn Văn A", "email": "signer@partner.com", "phone": "0901234567", "credentials": null }
    ],
    "signUrl": null,
    "expireAt": null,
    "prevActorName": "Nguyễn Văn A",
    "completeDate": "2026-06-18T11:44:00.000Z",
    "note": null,
    "metadata": { "tenantCode": "T001" }
  }
}
```

***

## Uỷ quyền gửi email

Mặc định econtractid gửi email cho **tất cả** người nhận. Khi bật uỷ quyền, econtractid **ngừng gửi email cho người nhận bên ngoài** — nhưng chỉ khi thoả **cả hai** điều kiện:

1. Event nằm trong danh sách uỷ quyền của tổ chức (quản trị viên cấu hình)
2. Tổ chức có ít nhất một webhook subscription đang hoạt động lắng nghe event đó

<Warning>
  Chỉ người nhận bên ngoài bị ngừng gửi; nhân sự nội bộ vẫn nhận email từ econtractid. Webhook hết retry mà chưa `2xx` thì **không có email bù** — theo dõi nhật ký gửi trên giao diện quản trị.
</Warning>

***

## Xác thực chữ ký

```go theme={null}
// Webhook secret được cấp khi onboarding
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(headerSig)) {
  http.Error(w, "invalid signature", 401)
  return
}

// Chống xử lý trùng — dùng X-Webhook-Id
webhookId := r.Header.Get("X-Webhook-Id")
if exists := checkProcessed(webhookId); exists {
  w.WriteHeader(200) // đã xử lý rồi, vẫn ack
  return
}
markProcessed(webhookId)

// Chống phát lại — createdAt nằm trong body nên đã được ký
envelope := unmarshalJSON(rawBody)
createdAt, _ := time.Parse(time.RFC3339, envelope.CreatedAt)
if time.Since(createdAt) > 5*time.Minute {
  http.Error(w, "payload too old", 401)
  return
}
```

***

## Retry

Nếu hệ thống của bạn trả về mã khác `2xx` hoặc timeout (10 giây), hệ thống sẽ gửi lại:

| Lần      | Chờ          | Tổng thời gian |
| -------- | ------------ | -------------- |
| 1        | ngay lập tức | 0s             |
| 2        | 1 giây       | 1s             |
| 3        | 10 giây      | 11s            |
| 4        | 1 phút       | 71s            |
| 5        | 10 phút      | 11 phút        |
| 6 (cuối) | 1 giờ        | \~71 phút      |

Sau lần thứ 6 thất bại, event được đánh dấu `EXHAUSTED`. Bạn khôi phục bằng cách gọi [`GET /integration/documents/{documentId}`](/api-reference/endpoint/ho-so).

***

## Khuyến nghị triển khai

| Việc                                      | Lý do                          |
| ----------------------------------------- | ------------------------------ |
| Trả `2xx` ngay, xử lý bất đồng bộ         | Timeout là 10 giây             |
| Lưu `X-Webhook-Id` để bỏ qua bản trùng    | Retry gửi lại cùng ID          |
| Xác thực chữ ký **trước khi** parse body  | Chặn payload giả mạo           |
| Kiểm tra `createdAt` không quá 5 phút     | Chống tấn công phát lại        |
| Dùng HTTPS với chứng chỉ hợp lệ           | Bắt buộc                       |
| Cảnh báo khi không nhận webhook quá 1 giờ | Dự phòng bằng endpoint tra cứu |
