Hướng dẫn AgentFlow

Sự kiện và webhook

Đăng ký nhận sự kiện qua HTTP — danh mục sự kiện, phong bì chỉ mang định danh, cách xác minh chữ ký, cửa sổ thử lại, tạm dừng tự động, xoay khóa ký, và vì sao người nhận phải tự khử trùng lặp.

Vì sao cần

Hỏi đi hỏi lại (polling) thì hoặc là chậm, hoặc là tốn. Một đăng ký webhook đảo chiều: khi có việc xảy ra, AgentFlow gọi vào URL của bạn.

Một đăng ký gồm ba thứ: URL nhận, danh sách loại sự kiện, và danh tính mà nó thay mặt — một chủ thể dịch vụ. Danh tính ấy quyết định bạn nhìn thấy gì: một lần gửi chỉ bao giờ nói về những thứ chủ thể ấy được đọc.

Phong bì: chỉ định danh, không bao giờ giá trị

Thân của mỗi lần gửi là một phong bì JSON:

{
  "id": "0199a3f1-9c2e-7b41-9c3d-2f5a8b1e4d70",
  "type": "record.updated",
  "occurred_at": "2026-09-15T08:13:20.123456Z",
  "org_id": "0a5f…",
  "position": 1843221,
  "subject": { "kind": "record", "id": "7d2e…", "entity_id": "0a5f…" },
  "changed_fields": ["so_tien"],
  "record_version": 42
}
TrườngÝ nghĩa
idĐịnh danh của sự kiện. Bằng đúng header webhook-id, và không đổi qua các lần gửi lại. Đây là khóa để khử trùng lặp.
typeLoại sự kiện, ví dụ record.updated.
occurred_atThời điểm việc xảy ra, RFC 3339 theo UTC.
positionSố thứ tự tăng dần, gán sau khi giao dịch kết thúc. Dùng để đối chiếu, không phải để suy ra thứ tự nhận.
subjectThứ mà sự kiện nói tới: kind là record, instance hay work, kèm id và các định danh liên quan (entity_id; workflow_id và parent_instance_id; instance_id). Với công việc, id là id của bản ghi.
changed_fieldsChỉ có ở sự kiện hồ sơ: tên các trường đã đổi.
record_versionChỉ có ở sự kiện hồ sơ: phiên bản của hồ sơ sau thay đổi.

Phong bì không mang giá trị. Không có nội dung trường, không có ai đã thao tác, không có chủ sở hữu. Muốn biết giá trị, hãy đọc hồ sơ bằng chính khóa của bạn — và khi đó bạn đọc nó với đúng quyền của mình, tại thời điểm đọc.

Danh mục đầy đủ nằm ở GET /webhook-event-types: 16 loại sự kiện thuộc ba nhóm chủ thể (instance, record, work), mỗi loại kèm một câu mô tả. Hãy đọc danh mục từ API thay vì chép cứng danh sách.

Không có sự kiện task. Một workflow task là bản ghi của thực thể Workflow task, nên việc mở, nhận, trả lại, giao lại và hoàn tất nó đến dưới dạng các sự kiện record.* và work.* của bản ghi đó, và subject.workflow_id là quy trình đã mở nó.

Ai nhận được gì

Quyền được quyết định lại trước mỗi lần thử, không phải một lần lúc đăng ký:

  • Một sự kiện về thứ mà chủ thể dịch vụ không được đọc thì không tạo ra lần gửi nào có nội dung — nó được ghi lại là skipped, với skipped_reason là subject_not_visible.
  • Với sự kiện hồ sơ, changed_fields được thu hẹp còn những trường chủ thể ấy đọc được. Nếu sau khi thu hẹp không còn trường nào, lần gửi là skipped với lý do no_visible_changes — một trường nhạy cảm đổi giá trị không tự nó báo cho người không được đọc nó.
  • Nếu chủ thể dịch vụ bị vô hiệu hóa, mọi lần gửi là skipped với lý do principal_disabled, và principal_disabled_at xuất hiện trên đăng ký. Đó là một điều kiện bên cạnh status, không phải một trạng thái: bật lại chủ thể không làm đổi status.
  • Mất quyền giữa hai lần thử cũng được tôn trọng: lần thử sau không được gửi đi.

Ai được đặt tên một chủ thể dịch vụ: một chủ thể dịch vụ chỉ được đặt tên chính nó; một người cần thêm deployment.admin bên cạnh webhook.manage. Vi phạm nhận 403 WEBHOOK_PRINCIPAL_NOT_PERMITTED. Quy tắc ấy áp dụng cho cả việc sửa đăng ký theo hướng đổi đích đến (URL, loại sự kiện, include_child_runs) và việc xoay khóa ký — vì cả hai đều quyết định điều gì được gửi đi thay mặt danh tính ấy. Đổi mỗi phần mô tả thì chỉ cần webhook.manage.

Xác minh chữ ký

Mỗi yêu cầu mang ba header, theo Standard Webhooks:

webhook-id: 0199a3f1-9c2e-7b41-9c3d-2f5a8b1e4d70
webhook-timestamp: 1789460000
webhook-signature: v1,K5oT8s…=

Chữ ký là v1, cộng base64 của HMAC-SHA256(khóa, "{id}.{timestamp}.{thân yêu cầu nguyên văn}"). Khóa ký được trả về đúng một lần, dạng whsec_ cộng base64 của 32 byte; phần sau whsec_ mới là khóa.

Dùng một thư viện Standard Webhooks nếu có. Làm bằng tay thì như sau:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, headers, rawBody) {
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${rawBody}`;
  const expected = createHmac('sha256', key).update(signed).digest('base64');
  // Header có thể mang nhiều chữ ký, cách nhau bằng dấu cách.
  return headers['webhook-signature']
    .split(' ')
    .some((part) => {
      const got = Buffer.from(part.replace(/^v1,/, ''), 'base64');
      const want = Buffer.from(expected, 'base64');
      return got.length === want.length && timingSafeEqual(got, want);
    });
}

Ba điều dễ sai:

  • Ký trên thân nguyên văn, đúng từng byte đã nhận. Đừng parse rồi in lại JSON.
  • So sánh theo thời gian hằng định (timingSafeEqual), đừng dùng ===.
  • Chấp nhận nhiều chữ ký: trong thời gian chồng lấn sau khi xoay khóa, header mang mỗi khóa còn hiệu lực một chữ ký, mới nhất đứng trước.

Nên kiểm tra thêm webhook-timestamp lệch không quá vài phút so với đồng hồ của bạn, để loại các yêu cầu phát lại.

Trả lời thế nào

Trả về bất kỳ mã 2xx là thành công. Mọi mã khác, hết thời gian chờ, hay lỗi kết nối đều là thất bại và sẽ được thử lại.

Hãy trả lời nhanh: yêu cầu có hạn 10 giây (bản triển khai có thể đặt khác). Nhận xong thì ghi vào hàng đợi của bạn rồi trả 204 ngay, đừng xử lý xong mới trả lời. Phần thân bạn trả về được đọc tối đa 64 KiB rồi bỏ qua.

Cửa sổ thử lại

Cửa sổ được tính bằng thời gian, không phải bằng số lần thử. Mặc định 24 giờ kể từ khi lần gửi sẵn sàng (bản triển khai có thể đặt trong khoảng 24–168 giờ), và retry_deadline_at trên mỗi lần gửi nói rõ hạn ấy.

Khoảng cách giữa các lần thử, sau lần thất bại thứ 1, 2, … lần lượt là:

Sau lần thất bại thứChờ
130 giây
22 phút
310 phút
430 phút
51 giờ
62 giờ
74 giờ
8 trở đi8 giờ

Mỗi khoảng được cộng trừ ngẫu nhiên tối đa 10%, và bị kẹp lại để không vượt quá hạn. Một lần gửi chỉ thất bại hẳn khi một lần thử được thực hiện tại hoặc sau hạn mà vẫn hỏng — nghĩa là một người nhận hồi phục ở bất cứ đâu trong cửa sổ đều còn ít nhất một lần thử nữa.

Tạm dừng tự động, lần gửi bị giữ, và hết hạn

Khi mọi lần thử đều hỏng liên tục trong 72 giờ, đăng ký được tự động chuyển sang paused_failing. Đó là để bảo vệ cả hai phía: bạn khỏi một dòng yêu cầu vô ích, chúng tôi khỏi việc gửi mãi vào một nơi đã tắt.

Khi đăng ký đang tạm dừng (do bạn hay do tự động), sự kiện khớp vẫn được ghi nhận, dưới dạng lần gửi ở trạng thái held. Khi bạn POST .../resume, chúng được thả ra cũ trước mới sau, mỗi cái với một cửa sổ mới.

Một lần gửi bị giữ quá lâu sẽ hết hạn cùng sự kiện của nó (sự kiện được lưu 30 ngày). Mỗi đợt hết hạn được ghi vào lịch sử, kèm số lượng theo từng loại sự kiện và mốc thời gian cũ nhất, mới nhất:

{
  "kind": "deliveries_expired",
  "occurred_at": "2026-09-13T02:00:00Z",
  "expired": {
    "count": 14,
    "count_by_event_type": { "instance.completed": 9, "instance.failed": 5 },
    "oldest_event_occurred_at": "2026-09-06T…",
    "newest_event_occurred_at": "2026-09-13T…"
  }
}

Đó là cách đối chiếu lại sau một sự cố: đọc GET /webhook-subscriptions/{id}/history, xem đã mất gì và trong khoảng nào, rồi đọc lại trạng thái hiện tại của những hồ sơ hay lượt chạy ấy qua API. Lịch sử cũng ghi created, updated (kèm tên các thiết lập đã đổi, không kèm giá trị), paused, resumed, secret_rotated, secrets_revoked và redelivery_requested.

Muốn gửi lại một lần gửi cụ thể: POST .../deliveries/{delivery_id}/redeliver. Lần gửi mới mang cùng webhook-id. Nếu sự kiện đã quá hạn lưu, bạn nhận 410 WEBHOOK_EVENT_NOT_RETAINED.

Xoay khóa ký

POST /webhook-subscriptions/{id}/rotate-secret trả về khóa mới, hiển thị một lần. Khóa cũ vẫn ký tiếp trong thời gian chồng lấn (mặc định 24 giờ), nên bạn có thời gian triển khai khóa mới mà không mất lần gửi nào — đó là lý do người nhận phải chấp nhận nhiều chữ ký.

Tối đa 5 khóa được ký cùng lúc. Vượt quá, yêu cầu bị từ chối 409 WEBHOOK_SECRET_ROTATION_LIMIT, kèm thời điểm chồng lấn cũ nhất kết thúc. Khi một khóa bị lộ, hãy gửi {"revoke_previous": true}: mọi khóa cũ ngừng ký ngay, và lần xoay ấy không bao giờ bị giới hạn trên chặn.

Chính sách mạng

  • URL nhận phải là https. URL không hợp lệ bị từ chối 422 WEBHOOK_URL_INVALID.
  • Địa chỉ nội bộ bị từ chối theo mặc định (422 WEBHOOK_URL_EGRESS_DENIED). Tên miền được phân giải một lần rồi gọi thẳng vào địa chỉ đã phân giải, nên không thể đổi đích giữa lúc kiểm tra và lúc gọi.
  • Chuyển hướng không bao giờ được đi theo. Một 301/302 được ghi nhận là thất bại (redirect_refused). Hãy đăng ký thẳng URL cuối cùng.

Số yêu cầu đồng thời

Với mỗi đăng ký, tối đa 10 yêu cầu được gửi cùng lúc. Người nhận của bạn không bị dội, và một đăng ký chậm không chiếm hết năng lực của các đăng ký khác.

Thứ tự không được bảo đảm

Thử lại, giữ rồi thả, và gửi song song đều làm thứ tự nhận khác thứ tự xảy ra. Vì vậy:

  • Khử trùng lặp theo webhook-id. Cùng một sự kiện có thể tới hai lần — sau một lần hết thời gian chờ mà thực ra đã tới, hay sau một lần gửi lại. Hãy ghi nhớ id đã xử lý và bỏ qua id trùng.
  • So record_version trước khi ghi đè trạng thái của một hồ sơ trong hệ thống của bạn. Phiên bản nhỏ hơn cái bạn đang giữ là tin cũ, hãy bỏ qua.
  • Đừng suy ra thứ tự từ position giữa các loại sự kiện khác nhau; nó tăng dần nhưng lần nhận thì không.

Mã lỗi

Trạng tháiMã (type)Khi nào
403WEBHOOK_PRINCIPAL_NOT_PERMITTEDNgười gọi không được đặt tên chủ thể dịch vụ này (xem ở trên).
422WEBHOOK_URL_INVALIDURL nhận không phải https, hoặc không đọc được.
422WEBHOOK_URL_EGRESS_DENIEDURL nhận trỏ vào một địa chỉ mà bản triển khai từ chối gọi ra.
422WEBHOOK_EVENT_TYPE_UNKNOWNMột loại sự kiện không có trong danh mục.
422WEBHOOK_PRINCIPAL_INACTIVEChủ thể dịch vụ được đặt tên đang bị vô hiệu hóa.
409WEBHOOK_SECRET_ROTATION_LIMITĐã có 5 khóa đang ký. Chờ hết chồng lấn, hoặc xoay kèm revoke_previous.
410WEBHOOK_EVENT_NOT_RETAINEDSự kiện của lần gửi ấy đã quá hạn lưu, không gửi lại được nữa.

Toàn bộ danh mục nằm ở Mã lỗi; cách đọc một lỗi nằm ở Lỗi.

Đọc tiếp

  • Ví dụ: webhook — tạo đăng ký, nhận một lần gửi, xác minh, rồi đọc hồ sơ.
  • Xác thực — chủ thể dịch vụ, khóa và phạm vi quyền.
  • Thử lại an toàn — Idempotency-Key khi tạo đăng ký hay gửi lại.

On this page