Skip to main content

Webhook

Tổng quan​

Webhook giúp hệ thống của bạn nhận thay đổi ngay khi nó xảy ra, không phải gọi Open API định kỳ để hỏi "có gì mới không". Khi khách hàng, đơn hàng… thay đổi trên MiniAI SCRM, hệ thống gửi một request POST tới URL của bạn, kèm dữ liệu mới nhất và chữ ký để bạn kiểm tra request đó là thật.

Sự kiệnKhi nào
customer.createdCó khách hàng mới
customer.updatedThông tin khách hàng thay đổi
interaction.createdCó ghi chú mới trên hồ sơ khách
order.createdCó đơn hàng mới
order.updatedĐơn hàng thay đổi (gồm cả đổi trạng thái)
object_record.upsertedBản ghi trong bảng dữ liệu được tạo hoặc cập nhật
Chưa mở cho tất cả cửa hàng

Open API và Webhook đang được bật theo yêu cầu, chưa mở sẵn cho mọi tài khoản. Nếu bạn muốn kết nối hệ thống của mình với MiniAI SCRM, hãy liên hệ đội ngũ MiniAI (người phụ trách tài khoản của bạn) để được bật tính năng.

Webhook + Open API

Cách dùng phổ biến: webhook báo "đơn DH-1001 vừa đổi", hệ thống của bạn cập nhật ngay; còn Open API dùng để đồng bộ dữ liệu cũ lần đầu và đối soát định kỳ (phòng khi URL của bạn bị gián đoạn lâu).


Tạo webhook​

  1. Đăng nhập MiniAI SCRM bằng tài khoản có quyền quản lý webhook.
  2. Bấm vào tên của bạn ở góc dưới bên trái → Cài đặt, chọn API & Webhook (nhóm Workspace), tab Webhook, bấm Thêm webhook.
  3. Điền:
    • Tên — để bạn nhận ra, VD Đồng bộ ERP.
    • URL nhận — địa chỉ https:// của hệ thống bạn.
    • Sự kiện — chọn những sự kiện cần nhận. Mỗi sự kiện có Payload mẫu để bạn xem trước dữ liệu.
  4. Bấm lưu. Hệ thống gọi thử URL (gửi sự kiện ping) — URL phải trả 2xx với body {"success":"ok"} thì webhook mới được lưu (xem Xác minh URL khi lưu). Lưu xong, hệ thống hiện Secret của webhook — sao chép và lưu lại ngay, secret chỉ hiển thị một lần. Bạn dùng secret này để xác minh chữ ký.
  5. Mở lại webhook (Sửa) và bấm Gửi thử ở một sự kiện để kiểm tra URL đã nhận được request chưa (xem Gửi thử).
Mỗi cửa hàng một webhook

Mỗi cửa hàng có 1 webhook. Muốn đổi URL hoặc sự kiện ⇒ bấm Sửa (secret giữ nguyên; đổi URL thì URL mới cũng phải qua bước xác minh). Muốn đổi secret ⇒ xoá webhook hiện tại rồi tạo webhook mới.

Yêu cầu với URL nhận​

  • Phải là https://, trỏ tới địa chỉ công khai trên Internet (không nhận localhost, IP nội bộ).
  • Không chuyển hướng (redirect) — trả 3xx bị tính là thất bại.
  • Trả mã HTTP 2xx (200, 201, 202…) với body JSON {"success":"ok"} trong vòng 10 giây. Mã ngoài 2xx hoặc body khác (kể cả 204 không có body) đều bị tính là thất bại.
HTTP/1.1 200 OK
Content-Type: application/json

{"success":"ok"}

Xác minh URL khi lưu​

Khi bạn tạo webhook hoặc đổi URL, hệ thống gửi ngay một request ping tới URL đó và chỉ lưu khi nhận được 2xx + {"success":"ok"}. Không đạt ⇒ báo lỗi kèm lý do (VD HTTP 401, HTTP 200 nhưng body không phải {"success":"ok"}, TIMEOUT) và không lưu.

{
"id": "evt_1c9e…",
"event": "ping",
"shopId": "5b3c…",
"occurredAt": "2026-10-09T08:00:00.000Z",
"source": "ui",
"test": true,
"data": {}
}
Chưa có secret lúc tạo webhook

Secret chỉ hiện sau khi webhook được lưu, nên lúc tạo bạn chưa xác minh được chữ ký của ping. Cách đơn giản: để URL trả 200 + {"success":"ok"} trước, lưu webhook, lấy secret rồi mới bật bước xác minh chữ ký. Hoặc luôn trả ack cho request có x-webhook-event: ping mà không kiểm chữ ký (như code mẫu bên dưới) — ping không mang dữ liệu nào.


Request gửi tới bạn​

POST <URL nhận>
Content-Type: application/json
User-Agent: miniai-webhooks/1.0
x-webhook-id: 2f6c1b0e-… ← mã lần gửi (delivery)
x-webhook-event: order.updated
x-webhook-timestamp: 1791100800 ← Unix time (giây) lúc gửi
x-webhook-signature: 5d41402a… ← hex(HMAC-SHA256(secret, timestamp + "." + body))

Body luôn có cùng một khung:

{
"id": "evt_8a3f2c1e9b7d4e6fa0c5b2d1e3f4a5b6",
"event": "order.updated",
"shopId": "5b3c…",
"occurredAt": "2026-10-09T08:00:00.000Z",
"source": "ui",
"data": { "order": { /* … */ } }
}
TrườngÝ nghĩa
idMã sự kiện — giữ nguyên qua mọi lần gửi lại. Dùng để khử trùng.
eventTên sự kiện (bảng ở Danh mục sự kiện)
shopIdCửa hàng phát sinh sự kiện
occurredAtThời điểm sự kiện xảy ra (ISO 8601, UTC)
sourceNguồn của thay đổi: ui (thao tác trên MiniAI SCRM) hoặc api:<tên key> (ghi qua Open API)
testChỉ có (và bằng true) khi là request Gửi thử
dataDữ liệu của sự kiện — cùng hình dạng với Open API
Chống vòng lặp đồng bộ

Hệ thống của bạn vừa ghi đơn qua Open API bằng key ERP Kho thì webhook order.updated sau đó có source: "api:ERP Kho". Bỏ qua sự kiện có source là của chính bạn để không ghi ngược lại lần nữa.


Xác minh chữ ký​

Mỗi request được ký bằng secret của webhook. Hãy kiểm tra chữ ký trước khi xử lý để chắc request đến từ MiniAI SCRM và không bị sửa trên đường đi:

  1. Lấy body thô (đúng chuỗi byte nhận được — chưa parse JSON).
  2. Tính hex(HMAC-SHA256(secret, x-webhook-timestamp + "." + body_thô)).
  3. So với x-webhook-signature bằng phép so sánh an toàn thời gian (constant-time).
  4. Từ chối nếu x-webhook-timestamp lệch giờ hiện tại quá 5 phút (chống phát lại).
Ký trên body thô

Đừng JSON.parse rồi JSON.stringify lại để tính chữ ký — thứ tự khoá, khoảng trắng, ký tự Unicode có thể khác ⇒ chữ ký không khớp. Đọc body thô trước, xác minh xong mới parse.

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.MI9_WEBHOOK_SECRET; // whsec_…
const app = express();

app.post("/webhooks/miniai", express.raw({ type: "application/json" }), (req, res) => {
// ping (xác minh URL khi lưu) ⇒ trả ack ngay, không kiểm chữ ký
if (req.header("x-webhook-event") === "ping") return res.json({ success: "ok" });

const ts = req.header("x-webhook-timestamp");
const signature = req.header("x-webhook-signature") ?? "";
const raw = req.body.toString("utf8");

const expected = crypto
.createHmac("sha256", SECRET)
.update(`${ts}.${raw}`)
.digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
if (!valid || !fresh) return res.sendStatus(401);

const event = JSON.parse(raw);
res.json({ success: "ok" }); // trả 200 + ack ngay…
queueForProcessing(event); // …rồi xử lý bất đồng bộ
});

Danh mục sự kiện​

Dữ liệu trong data dùng cùng hình dạng với Open API, nên bạn dùng lại được code đọc dữ liệu đã viết cho Open API.

Sự kiệndataHình dạng đối tượng
customer.created{ customer }Đối tượng khách hàng
customer.updated{ customer, changedFields? }như trên; changedFields = các trường vừa đổi (tên trường chuẩn hoặc mã trường tuỳ chỉnh), có thể không có
interaction.created{ interaction }Đối tượng ghi chú
order.created{ order }Đối tượng đơn hàng (kèm dòng hàng và địa chỉ giao)
order.updated{ order }như trên — dữ liệu sau khi đổi
object_record.upserted{ objectKey, record }Bản ghi bảng dữ liệu

data luôn là toàn bộ đối tượng ở trạng thái mới nhất tại lúc gửi, không chỉ phần thay đổi — bạn ghi đè bản ghi phía mình là đủ.

{
"id": "evt_8a3f2c1e9b7d4e6fa0c5b2d1e3f4a5b6",
"event": "customer.updated",
"shopId": "5b3c…",
"occurredAt": "2026-10-09T08:00:00.000Z",
"source": "ui",
"data": {
"customer": {
"id": "0f8c2a3e-6b1d-4f5a-9c7e-2d4b6a8e1c3f",
"customerCode": "KH000123",
"fullName": "Nguyễn Văn A",
"phoneNumber": "0901234567",
"email": "nguyenvana@example.com",
"address": "12 Lý Thường Kiệt, Hoàn Kiếm, Hà Nội",
"companyName": "Công ty ABC",
"notes": null,
"gender": "male",
"dateOfBirth": "1990-05-20",
"status": { "id": 1, "name": "Tiềm năng" },
"assignedStaffId": "7a1e5c9b-3d2f-4e8a-b6c4-1f9d0e2a7b5c",
"customFields": { "nguon_kh": "website" },
"lastModifiedSource": "ui",
"createdAt": "2026-10-04T08:00:00.000Z",
"updatedAt": "2026-10-09T08:00:00.000Z"
},
"changedFields": ["phoneNumber", "nguon_kh"]
}
}
Thứ tự không được đảm bảo

Sự kiện thường đến theo thứ tự xảy ra, nhưng khi có gửi lại thì một sự kiện cũ có thể đến sau sự kiện mới hơn. So data.<đối tượng>.updatedAt với bản phía bạn và bỏ qua nếu cũ hơn.


Gửi thử​

Mở webhook bằng Sửa, mỗi sự kiện có Payload mẫu và nút Gửi thử. Bấm ⇒ hệ thống gửi ngay payload mẫu của sự kiện đó (dữ liệu giả, đúng hình dạng thật) tới URL đã lưu — vừa đổi URL thì lưu trước rồi mới gửi thử.

Request gửi thử có "test": true, được ký như request thật, gửi một lần (không gửi lại) và không tính vào số lần thất bại. Hệ thống của bạn nên xác minh chữ ký rồi bỏ qua phần xử lý nghiệp vụ khi gặp test: true.


Gửi lại khi thất bại​

Một lần gửi được coi là thành công khi URL của bạn trả 2xx với body {"success":"ok"} trong 10 giây. Mọi trường hợp khác (mã ngoài 2xx, body khác, quá thời gian, không kết nối được) là thất bại và được gửi lại tự động:

LầnThời điểm
1Ngay khi sự kiện xảy ra
25 phút sau lần 1 thất bại
330 phút sau lần 2
42 giờ sau lần 3 — lần cuối

Sau 4 lần đều thất bại, lần gửi đó chuyển sang Thất bại. Bạn vẫn có thể gửi lại thủ công.

Tự tắt webhook

Nếu 20 sự kiện liên tiếp đều thất bại hết lượt gửi lại, webhook bị tự tắt (trạng thái Đã tắt) và ngừng nhận sự kiện. Sửa xong phía bạn ⇒ bấm Bật lại trên tab Webhook. Chỉ cần một lần gửi thành công là bộ đếm về 0.

Nhận cùng một sự kiện nhiều lần là bình thường

Webhook đảm bảo gửi ít nhất một lần: nếu bạn đã xử lý nhưng phản hồi chậm quá 10 giây, hoặc có sự cố mạng, cùng một sự kiện có thể đến lại. Khử trùng theo id của body (mã sự kiện). x-webhook-id là mã của lần gửi — mỗi lần gửi lại thủ công có x-webhook-id mới nhưng id sự kiện giữ nguyên.


Lịch sử gửi​

Bấm Lịch sử gửi để xem 50 lần gửi gần nhất: sự kiện, kết quả (Đang chờ / Thành công / Thất bại), số lần thử, URL đã gửi tới, mã phản hồi hoặc lỗi, thời gian. Lịch sử được giữ 14 ngày.

Cột URL là địa chỉ của lần gửi gần nhất. Nếu bạn đã đổi URL sau đó, dòng cũ có nhãn URL cũ — giúp phân biệt lần gửi nào đi tới địa chỉ trước khi đổi.

Bấm Gửi lại ở một dòng để gửi lại đúng payload đó (cùng id sự kiện) — lần gửi mới cũng có đủ lượt gửi lại tự động như trên.

Mất sự kiện khi URL ngừng quá lâu?

Nếu hệ thống của bạn ngừng hoạt động lâu hơn khoảng thời gian gửi lại (~2,5 giờ) hoặc webhook đã tự tắt, hãy dùng Open API với updatedSince = thời điểm ngừng để đồng bộ bù.


Checklist cho hệ thống nhận​

  • Xác minh x-webhook-signature trên body thô, từ chối nếu x-webhook-timestamp lệch quá 5 phút.
  • Trả lời ping bằng 200 + {"success":"ok"} mà không kiểm chữ ký (để lưu được webhook).
  • Trả 200 + {"success":"ok"} ngay (dưới 10 giây), xử lý nặng ở hàng đợi phía bạn.
  • Khử trùng theo id sự kiện.
  • Bỏ qua sự kiện có updatedAt cũ hơn bản đang lưu.
  • Bỏ qua source là key Open API của chính bạn.
  • Bỏ qua phần nghiệp vụ khi test: true.
  • Lưu secret trong biến môi trường / kho bí mật, không commit vào mã nguồn.