Nhận các cập nhật từ hệ thống với Webhooks
Webhook giúp hệ thống của bạn (ví dụ phần mềm quản trị ERP, CRM,...) phản ứng tức thì khi có sự kiện quan trọng xảy ra
trên Selfomy: học viên mới được tạo, học viên đăng ký lớp, đơn hàng được thanh toán. Khi sự kiện xảy ra, Selfomy sẽ gửi
một POST request JSON đến URL bạn cấu hình.
Vị trí truy cập: Ảnh đại diện → Thông tin công ty → Webhooks.
Bắt đầu nhanh
1. Truy cập /company/webhooks (sidebar → Webhooks).
2. Bấm Add endpoint (Thêm endpoint).
3. Điền thông tin:
- Name (Tên) — nhãn để bạn dễ phân biệt (ví dụ: "Production", "Staging").
- Endpoint URL — URL HTTPS mà Selfomy sẽ POST tới.
- Signing secret (Khóa ký) (tùy chọn) — dùng để ký request bằng HMAC-SHA256 nhằm xác thực nguồn gốc. Bấm Regenerate để
tạo khóa ngẫu nhiên mạnh.
- Notification email (Email nhận cảnh báo) (tùy chọn) — nơi nhận email khi webhook bị tự động tắt. Để trống để dùng
email mặc định của công ty.
- Events (Sự kiện) — chọn các sự kiện muốn nhận. Bắt buộc chọn ít nhất 1.
- Active (Hoạt động) — bật/tắt.
- Bấm Save (Lưu).
Mỗi công ty được cấu hình tối đa 5 endpoint.
Sự kiện
- new_student_created — học viên mới được tạo.
- new_student_enrolled — học viên được đăng ký vào lớp.
- order_created — đơn hàng được thanh toán (lần thanh toán đầu tiên được ghi nhận).
- order_updated — đơn hàng đã thanh toán một phần nhận thêm khoản thanh toán mới.
- quiz_attempt_submitted — học viên nộp bài quiz (status chuyển thành submitted/grading/graded). Chỉ phát một lần cho
mỗi lần nộp; chấm lại không phát lại.
- quiz_group_attempt_submitted — bài làm trong phòng thi ảo vừa được nộp được nộp (tức đã làm xong đủ các bài thi).
- assignment_group_attempt_submitted — học viên nộp bài tập.
- material_attempt_submitted — học viên hoàn thành một học liệu.
- quiz_mocktest_created — phòng thi ảo vừa được tạo.
- quiz_attempt_graded — bài tập dạng đề thi được chấm xong.
- quiz_group_attempt_graded — bài làm trong phòng thi ảo được chấm xong.
- assignment_group_attempt_graded — bài tập được chấm xong.
Cấu trúc payload
Mọi payload là JSON với khung chung:
{
"type": "<mã_sự_kiện>",
"data": { /* dữ liệu riêng cho từng sự kiện */ }
}
new_student_created
{
"type": "new_student_created",
"data": {
"student": { "uuid", "internalId", "fullName", "firstName", "lastName", "avatarUrl", "dateOfBirth", "school", "phone", "address", "email", "parentName", "parentPhone", "notes", "createdAt", "updatedAt" },
"branch": { "uuid", "name", "email", "phone", "address", "taxCode", "createdAt", "updatedAt" }
}
}
new_student_enrolled
{
"type": "new_student_enrolled",
"data": {
"classroomEnrollment": { "uuid", "startAt", "endAt", "customTuitionFees", "createdAt", "updatedAt" },
"classroom": { "uuid", "title", "timeSlotName", "isActive", "startAt", "endAt", "customPrice", "createdAt", "updatedAt" },
"student": { /* same shape as new_student_created.student */ },
"branch": { /* same shape as new_student_created.branch */ }
}
}
order_created / order_updated
{
"type": "order_created",
"data": {
"order": { "uuid", "internalId", "paymentMethod", "status", "amount", "tax", "discount", "totalDiscount", "totalAmount", "prepaidAmount", "paidAmount", "remainingAmount", "total", "createdAt", "updatedAt" },
"orderItems": [ { "description", "isTopup", "amount", "tax", "discount", "createdAt", "updatedAt" } ],
"student": { /* same shape as new_student_created.student */ } | null,
"branch": { /* same shape as new_student_created.branch */ }
}
}
quiz_attempt_submitted
{
"type": "quiz_attempt_submitted",
"data": {
"quizAttempt": { "uuid", "attempt", "status", "startAt", "endAt", "sumGrades", "totalQuestions", "correctAnswers", "overallScore", "overallFeedback", "createdAt", "updatedAt" },
"quiz": { "uuid", "title", "description", "timeLimit", "numberOfRetakes", "passingScore", "createdAt", "updatedAt" },
"student": { /* cùng shape với new_student_created.student */ } | null,
"branch": { /* cùng shape với new_student_created.branch */ } | null
}
}
quiz_group_attempt_submitted
{
"type": "quiz_group_attempt_submitted",
"data": {
"quizGroupAttempt": { "uuid", "status", "startAt", "endAt", "overallScore", "feedback", "createdAt", "updatedAt" },
"quizMocktest": { "uuid", "title", "description", "startAt", "endAt", "examMode", "gradingMode", "createdAt", "updatedAt" } | null,
"student": { /* ... */ } | null,
"branch": { /* ... */ } | null
}
}
assignment_group_attempt_submitted
{
"type": "assignment_group_attempt_submitted",
"data": {
"assignmentGroupAttempt": { "uuid", "status", "startAt", "endAt", "overallScore", "gradedAt", "createdAt", "updatedAt" },
"assignmentGroup": { "uuid", "title", "description", "timeLimit", "displayFormat", "createdAt", "updatedAt" } | null,
"student": { /* ... */ } | null,
"branch": { /* ... */ } | null
}
}
material_attempt_submitted
{
"type": "material_attempt_submitted",
"data": {
"materialAttempt": { "uuid", "status", "endAt", "isLate", "createdAt", "updatedAt" },
"material": { "uuid", "title", "content", "externalImageUrl", "createdAt", "updatedAt" } | null,
"student": { /* ... */ } | null,
"branch": { /* ... */ } | null
}
}
student và branch có thể là null trong các event *_attempt_submitted khi attempt được thực hiện bởi người dùng ẩn
danh/MOOC không gắn học viên. material.content được cắt còn 500 ký tự để giữ payload nhỏ gọn; gọi API nếu cần đầy đủ.
quiz_attempt_graded / quiz_group_attempt_graded / assignment_group_attempt_graded
Payload giống hệt event *_submitted tương ứng. Chỉ khác giá trị type.
Lưu ý: khi chấm lại (Graded → Grading → Graded) sẽ phát sinh event thêm lần nữa. Nếu bạn dùng event để gửi điểm cho học
viên, có thể so sánh trường gradedAt để tránh gửi hai lần hoặc chấp nhận cùng một lần chấm có thể đến hai lần.
Đối với các dạng bài chấm tự động như Listening/Reading có thể gửi cả event *_submitted và *_graded cho cùng một bài làm
trong thời gian ngắn.
quiz_mocktest_created
{
"type": "quiz_mocktest_created",
"data": {
"quizMocktest": { "uuid", "title", "description", "startAt", "endAt", "examMode", "gradingMode", "createdAt", "updatedAt" }
}
}
Header của request
Mỗi request gửi đi đều kèm các header sau:
- Content-Type — luôn là application/json.
- X-Selfomy-Event — mã sự kiện (ví dụ: order_created).
- X-Selfomy-Timestamp — thời điểm gửi, định dạng ISO 8601.
- X-Selfomy-Signature — chữ ký HMAC-SHA256 của body (hex). Chỉ gửi khi đã đặt signing secret.
- X-Selfomy-Signature-Algo — hiện tại luôn là sha256. Chỉ gửi khi có signing secret.
Xác thực chữ ký
Nếu bạn đặt signing secret, hệ thống nhận webhook nên từ chối mọi request có chữ ký không khớp. Chữ ký được tính trên
body thô của request dùng secret của bạn.
Ví dụ PHP
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $body, $SECRET_CUA_BAN);
if (! hash_equals($expected, $_SERVER['HTTP_X_SELFOMY_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
Ví dụ Node.js
import crypto from 'node:crypto';
app.post('/hooks', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', SECRET_CUA_BAN).update(req.body).digest('hex');
if (expected !== req.header('X-Selfomy-Signature')) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString());
// xử lý payload...
res.sendStatus(200);
});
Chống replay
Nên từ chối request có X-Selfomy-Timestamp cũ hơn mốc cho phép (ví dụ: 5 phút) để chống tấn công replay.
Tự động tắt sau nhiều lần thất bại liên tiếp
Để bảo vệ cả receiver và worker gửi sự kiện, Selfomy sẽ tự động tắt webhook sau 5 lần gửi thất bại liên tiếp (mọi
response không phải 2xx hoặc lỗi mạng đều tính là thất bại).
Khi điều này xảy ra:
- Trạng thái webhook chuyển sang Không hoạt động — không còn gửi sự kiện nào nữa.
- Một email cảnh báo được gửi tới Email thông báo của webhook nếu có, ngược lại là email công ty.
- Bảng "Lịch sử gửi gần đây" hiển thị các lần thất bại để bạn chuẩn đoán.
Để bật lại: sửa receiver, mở webhook trong panel, và bấm Lưu để hệ thống gửi lại cho những lần sau.
Test ping
Để kiểm tra xem hệ thống có nhận được dữ liệu, hãy bấm nút Ping test. Khi bấm, hệ thống gửi:
{
"type": "ping",
"data": { "sent_at": "<ISO 8601>", "source": "test" }
}
Test ping đi qua cùng quy trình ký + header như sự kiện thật, nên đây là cách tốt để xác nhận receiver hoạt động và chữ
ký được xác thực đúng.
Test ping cũng được tính vào số lần thất bại, nếu 5 test ping liên tiếp đều thất bại thì webhook sẽ tự động tắt.
Lịch sử gửi gần đây
Mở webhook để xem lịch sử gửi (chỉ lưu 30 ngày gần nhất). Mỗi dòng sẽ hiển thị:
- Thời gian gửi
- Loại sự kiện
- Trạng thái thành công / thất bại
- Xem payload — xem toàn bộ request + response JSON
- Gửi lại — gửi lại payload đã lưu, dùng URL/secret/header hiện tại (không phải các giá trị tại thời điểm gửi gốc)
Tính năng Gửi lại hữu ích khi receiver tạm thời gặp sự cố và bạn muốn gửi lại các sự kiện bị bỏ lỡ.
Giới hạn kỹ thuật
- Số endpoint tối đa mỗi công ty — 5.
- Thời gian lưu lịch sử gửi — 30 ngày (tự động dọn).
- Ngưỡng tự động tắt — 5 lần thất bại liên tiếp.
- Thuật toán chữ ký — HMAC-SHA256 (chỉ khi đặt secret).
Khuyến nghị cho receiver
1. Luôn xác thực chữ ký trước khi tin tưởng nội dung — bất kỳ ai cũng có thể POST đến URL công khai.
2. Phản hồi nhanh — trả 2xx trong vài giây. Việc nặng nên đẩy sang background job.
3. Idempotent — receiver có thể nhận cùng sự kiện 2 lần (ví dụ: sau khi Resend). Dùng UUID của entity + loại sự kiện
làm khóa khử trùng.
4. Từ chối timestamp cũ — chống replay bằng cách so X-Selfomy-Timestamp với thời điểm hiện tại.
5. Ghi log đầy đủ — ít nhất là loại sự kiện, kết quả xác thực chữ ký, và mã HTTP bạn trả về.
6. Bắt buộc HTTPS — Selfomy không chấp nhận URL http://.
Câu hỏi thường gặp
Tại sao webhook của tôi bị tự động tắt?
Có 5 lần gửi liên tiếp thất bại. Mở webhook để xem response lỗi, sửa receiver rồi kích hoạt lại webhook.
Tôi có thể tạo webhook riêng cho staging và production không?
Được — đó chính là mục đích của tính năng nhiều endpoint. Đặt tên rõ ràng và chọn sự kiện phù hợp cho từng endpoint.
Receiver của tôi xử lý chậm thì sao?
Bạn nên phản hồi trong vòng 15 giây. Nếu việc xử lý nặng, hãy nhận request, queue job, và trả 2xx ngay.
Secret_key có nằm trong payload không?
Không. Nó chỉ được dùng để tính X-Selfomy-Signature. Không bao giờ log, không bao giờ trả về.
Một endpoint có thể nhận nhiều loại sự kiện không?
Được — chọn nhiều sự kiện trên cùng một endpoint. Trường type cho biết bạn đang nhận sự kiện nào.