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ện | Khi nào |
|---|---|
customer.created | Có khách hàng mới |
customer.updated | Thông tin khách hàng thay đổi |
interaction.created | Có ghi chú mới trên hồ sơ khách |
order.created | Có đơn hàng mới |
order.updated | Đơn hàng thay đổi (gồm cả đổi trạng thái) |
object_record.upserted | Bản ghi trong bảng dữ liệu được tạo hoặc cập nhật |
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.
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
- Đăng nhập MiniAI SCRM bằng tài khoản có quyền quản lý webhook.
- 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.
- Đ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.
- Tên — để bạn nhận ra, VD
- Bấm lưu. Hệ thống gọi thử URL (gửi sự kiện
ping) — URL phải trả2xxvớ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ý. - 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 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ậnlocalhost, IP nội bộ). - Không chuyển hướng (redirect) — trả
3xxbị 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ài2xxhoặc body khác (kể cả204khô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": {}
}
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 |
|---|---|
id | Mã sự kiện — giữ nguyên qua mọi lần gửi lại. Dùng để khử trùng. |
event | Tên sự kiện (bảng ở Danh mục sự kiện) |
shopId | Cửa hàng phát sinh sự kiện |
occurredAt | Thời điểm sự kiện xảy ra (ISO 8601, UTC) |
source | Nguồn của thay đổi: ui (thao tác trên MiniAI SCRM) hoặc api:<tên key> (ghi qua Open API) |
test | Chỉ có (và bằng true) khi là request Gửi thử |
data | Dữ liệu của sự kiện — cùng hình dạng với Open API |
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:
- Lấy body thô (đúng chuỗi byte nhận được — chưa parse JSON).
- Tính
hex(HMAC-SHA256(secret, x-webhook-timestamp + "." + body_thô)). - So với
x-webhook-signaturebằng phép so sánh an toàn thời gian (constant-time). - Từ chối nếu
x-webhook-timestamplệch giờ hiện tại quá 5 phút (chống phát lại).
Đừ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.
- Node.js (Express)
- Python (Flask)
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ộ
});
import hashlib, hmac, json, os, time
from flask import Flask, request, abort
SECRET = os.environ["MI9_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/webhooks/miniai")
def miniai_webhook():
# ping (xác minh URL khi lưu) ⇒ trả ack ngay, không kiểm chữ ký
if request.headers.get("x-webhook-event") == "ping":
return {"success": "ok"}, 200
ts = request.headers.get("x-webhook-timestamp", "")
signature = request.headers.get("x-webhook-signature", "")
raw = request.get_data() # bytes thô
expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(401)
event = json.loads(raw)
queue_for_processing(event) # xử lý bất đồng bộ
return {"success": "ok"}, 200
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ện | data | Hì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à đủ.
- customer.updated
- interaction.created
- order.created / order.updated
- object_record.upserted
{
"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"]
}
}
{
"id": "evt_1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f",
"event": "interaction.created",
"shopId": "5b3c…",
"occurredAt": "2026-10-09T08:00:00.000Z",
"source": "ui",
"data": {
"interaction": {
"id": 4821,
"customerId": "0f8c2a3e-6b1d-4f5a-9c7e-2d4b6a8e1c3f",
"externalId": null,
"content": "Khách hẹn gọi lại vào thứ Hai.",
"authorName": "Trần Thị B",
"source": "ui",
"createdAt": "2026-10-09T08:00:00.000Z",
"updatedAt": "2026-10-09T08:00:00.000Z"
}
}
}
{
"id": "evt_9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b",
"event": "order.created",
"shopId": "5b3c…",
"occurredAt": "2026-10-09T08:00:00.000Z",
"source": "api:ERP Kho",
"data": {
"order": {
"id": "3c5e7a9b-1d2f-4a6c-8e0b-5f7d9a1c3e2b",
"orderCode": "DH000456",
"customerId": "0f8c2a3e-6b1d-4f5a-9c7e-2d4b6a8e1c3f",
"statusId": "2",
"paymentStatus": "unpaid",
"subtotal": 500000,
"taxAmount": 50000,
"shippingFee": 30000,
"discountAmount": 0,
"totalAmount": 580000,
"paidAmount": 0,
"debtAmount": 580000,
"currency": "VND",
"shipping": {
"name": "Nguyễn Văn A",
"phone": "0901234567",
"address": "12 Lý Thường Kiệt",
"provinceCode": "01",
"province": "Thành phố Hà Nội",
"wardCode": "00070",
"ward": "Phường Hoàn Kiếm"
},
"assignedStaffId": "7a1e5c9b-3d2f-4e8a-b6c4-1f9d0e2a7b5c",
"source": "api:ERP Kho",
"items": [
{
"id": "9e1a3c5b-7d2f-4b8e-a6c0-2d4f6b8a0c1e",
"productId": "5b7d9f1a-3c2e-4d6b-8a0f-1e3c5a7b9d2f",
"variantId": null,
"productName": "Áo thun",
"variantName": "Đỏ / M",
"sku": "AT-DO-M",
"quantity": 2,
"unitPrice": 250000,
"discountAmount": 0,
"vatRate": 10,
"taxAmount": 50000,
"totalPrice": 550000,
"notes": null
}
],
"createdAt": "2026-10-09T08:00:00.000Z",
"updatedAt": "2026-10-09T08:00:00.000Z"
}
}
}
{
"id": "evt_4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e",
"event": "object_record.upserted",
"shopId": "5b3c…",
"occurredAt": "2026-10-09T08:00:00.000Z",
"source": "ui",
"data": {
"objectKey": "hop_dong",
"record": {
"id": "6d8f0b2c-4e1a-4c7d-9b3e-0a2c4e6f8b1d",
"objectKey": "hop_dong",
"customerId": "0f8c2a3e-6b1d-4f5a-9c7e-2d4b6a8e1c3f",
"fields": { "so_hop_dong": "HD-2026-001", "gia_tri": 120000000 },
"createdAt": "2026-10-09T08:00:00.000Z",
"updatedAt": "2026-10-09T08:00:00.000Z"
}
}
}
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ần | Thời điểm |
|---|---|
| 1 | Ngay khi sự kiện xảy ra |
| 2 | 5 phút sau lần 1 thất bại |
| 3 | 30 phút sau lần 2 |
| 4 | 2 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.
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.
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.
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-signaturetrên body thô, từ chối nếux-webhook-timestamplệch quá 5 phút. - Trả lời
pingbằng200+{"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
idsự kiện. - Bỏ qua sự kiện có
updatedAtcũ hơn bản đang lưu. - Bỏ qua
sourcelà 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.