Học viện hàng hóa — API cho bên thứ ba

API đọc nội dung học tập (khóa học → chủ đề → bài học → quiz) và thử thách (bộ câu hỏi độc lập, ngoài mọi khóa học) của Học viện hàng hóa. Chỉ đọc, chỉ GET, xác thực bằng header X-API-Key. Mỗi endpoint trong trang này có khối test gọi trực tiếp từ trình duyệt.

https://hvhh.sfi.com.vnBase URL
/api/v1Phiên bản

Tổng quan

Base URLhttps://hvhh.sfi.com.vn
Tiền tố phiên bản/api/v1
Định dạngJSON, Content-Type: application/json; charset=utf-8 (UTF-8, có dấu tiếng Việt)
Phương thứcChỉ GET (và OPTIONS cho preflight CORS)
curl -H "X-API-Key: hvhh_xxxxxxxx" https://hvhh.sfi.com.vn/api/v1/courses
Cây nội dung có 4 tầng: khóa học → chủ đề → bài học → quiz. Quiz nằm ở ba cấp — trong bài học, cuối chủ đề (endQuiz của chủ đề), và cuối khóa học (endQuiz của khóa học). Trạng thái đăng thừa hưởng xuống dưới: chủ đề thuộc khóa học draft không xuất hiện ở bất kỳ endpoint nào, và bài học của nó cũng vậy — kể cả khi bản thân chúng đã published.
Thử thách đứng ngoài cây đó. Một thử thách là bộ câu hỏi độc lập: không thuộc khóa học hay chủ đề nào, không có bài học — chỉ có câu hỏi. Câu hỏi bên trong dùng đúng cấu trúc Quiz ở §2, nên render được bằng cùng một hàm. Xem §1.6 và §1.7.

⚡ Cấu hình test — dùng cho mọi khối test trong trang

Danh mục endpoint

EndpointMô tảXác thựcCache/ETag
GET /api/health Health check + contentVersion để phát hiện nội dung đổi KHÔNG CẦN KEY Không
GET /api/v1/courses Danh sách toàn bộ khóa học đã đăng — endpoint vào của cây nội dung X-API-KEY Có
GET /api/v1/courses/{slug} Mục lục một khóa học: chủ đề + mục lục bài học + endQuiz cuối khóa X-API-KEY Có
GET /api/v1/topics/{slug} Toàn bộ một chủ đề: bài học + contentHtml + quiz + endQuiz X-API-KEY Có
GET /api/v1/lessons/{slug} Một bài học đơn lẻ (deep link), kèm topic tối thiểu X-API-KEY Có
GET /api/v1/challenges Danh sách thử thách đã đăng — bộ câu hỏi độc lập, ngoài mọi khóa học X-API-KEY Có
GET /api/v1/challenges/{slug} Bộ câu hỏi của một thử thách — ?mode=full|random, ?limit=N X-API-KEY Chỉ mode=full

Tổng cộng 7 endpoint. Không có endpoint ghi, không có endpoint quiz riêng, không có danh sách bài học phẳng (xem §8). Hai endpoint thử thách là duy nhất có tham số query.

1

Endpoint

Tổng cộng 7 endpoint: 1 health check (không cần key) và 6 endpoint nội dung. Mỗi endpoint có khối test riêng, dùng Base URL / key ở khối Cấu hình test.

1.1 · Health check

GET KHÔNG CẦN KEY

/api/health

Kiểm tra service. Không cần API key — hữu ích cho monitor và cho việc phát hiện nội dung vừa đổi (§7). Endpoint này không đi qua cache và không có ETag.

Chỉ gọi được từ máy chủ, không gọi được từ trình duyệt. CORS chỉ bật cho /api/v1/*, nên /api/health không trả Access-Control-Allow-Origin và bị trình duyệt chặn khi gọi cross-origin. Dùng curl hoặc gọi từ backend của bạn:
curl https://hvhh.sfi.com.vn/api/health

Response

{ "ok": true, "service": "sfi-hvhh", "contentVersion": 7 }
FieldKiểuGiải thích
okbooleanLuôn true khi service sống. Nếu không nhận được JSON này thì service đang chết hoặc đang deploy
servicestringLuôn "sfi-hvhh". Dùng để chắc chắn không gọi nhầm service khác đứng sau cùng domain
contentVersionnumberSố phiên bản nội dung hiện tại, tăng 1 sau mỗi thao tác ghi nội dung. Reset về 1 mỗi lần service khởi động lại, nên chỉ dùng để phát hiện có thay đổi, không dùng như số tăng đơn điệu vĩnh viễn

Test — GET /api/health

chưa gọi

1.2 · Danh sách khóa học

GET X-API-KEY

/api/v1/courses

Danh sách toàn bộ khóa học đã đăng, đã sắp thứ tự. Đây là endpoint vào của cây nội dung — dùng cho trang danh sách khóa học.

Response 200

{
  "data": [
    {
      "id": "clx7k2h9d9000abcd1234efgh",
      "slug": "giao-dich-hang-hoa-phai-sinh-co-ban",
      "title": "Giao dịch hàng hóa phái sinh cơ bản",
      "description": "Khóa học nền tảng về giao dịch hàng hóa phái sinh.",
      "coverImageUrl": "https://cdn.example.com/hvhh/khoa-hoc-1.jpg",
      "sortOrder": 0,
      "topicCount": 2,
      "hasEndQuiz": true,
      "topics": [
        {
          "id": "clx7k2h9d0000abcd1234efgh",
          "slug": "gioi-thieu-hang-hoa",
          "title": "Giới thiệu hàng hóa",
          "description": "Kiến thức nền tảng về thị trường hàng hóa phái sinh.",
          "coverImageUrl": "https://cdn.example.com/hvhh/anh-bia.jpg",
          "sortOrder": 0,
          "lessonCount": 1,
          "hasEndQuiz": true,
          "lessons": [
            {
              "id": "clx7k2h9d0001abcd1234efgh",
              "slug": "hop-dong-tuong-lai-la-gi",
              "title": "Hợp đồng tương lai là gì?",
              "coverImageUrl": "https://cdn.example.com/hvhh/bai-1.jpg",
              "sortOrder": 0,
              "updatedAt": "2026-08-21T07:30:00.000Z"
            }
          ],
          "updatedAt": "2026-08-21T07:30:00.000Z"
        }
      ],
      "updatedAt": "2026-08-22T07:30:00.000Z"
    }
  ],
  "total": 1
}

Field cấp ngoài

FieldKiểuNull?Giải thích
dataarraykhôngMảng khóa học, đã sắp theo sortOrder tăng dần → đồng hạng thì khóa học tạo trước đứng trước. Mảng rỗng nếu chưa có khóa học nào được đăng
totalnumberkhôngSố phần tử trong data. Vì không phân trang, luôn bằng data.length — có mặt để client không phải đếm và để sau này thêm phân trang không phá vỡ hợp đồng

Mỗi phần tử trong data — đối tượng CourseSummary

FieldKiểuNull?Giải thích
idstringkhôngKhóa chính bền vững của khóa học. Dùng làm khóa lưu trữ / đối chiếu ở phía bạn
slugstringkhôngĐịnh danh dạng URL, duy nhất toàn hệ thống. Dùng để gọi /api/v1/courses/{slug}. Có thể đổi nếu tiêu đề thay đổi
titlestringkhôngTên khóa học, tiếng Việt có dấu
descriptionstringcóMô tả ngắn dạng văn bản thuần (không phải HTML). null khi không có nội dung
coverImageUrlstringcóURL tuyệt đối https:// tới ảnh bìa khóa học. null khi không có ảnh
sortOrdernumber (int)khôngThứ hạng sắp xếp, từ 0, không đảm bảo liên tục/duy nhất. Thứ tự đúng là thứ tự mảng
topicCountnumber (int)khôngSố chủ đề đã đăng trong khóa học (chủ đề draft không tính). Có thể là 0
hasEndQuizbooleankhôngtrue nếu khóa học có ít nhất một câu hỏi đã đăng ở quiz cuối khóa. Dùng để hiện nhãn "có bài kiểm tra cuối khóa" mà không cần gọi endpoint chi tiết
topicsarraykhông (có thể [])Chủ đề đã đăng của khóa học kèm mục lục bài học, cấu trúc giống hệt topics[] ở §1.3 (CourseTopic). lessons[] của mỗi chủ đề chỉ có id, slug, title, coverImageUrl, sortOrder, updatedAt — không có contentHtml, không có quizzes. Quiz cuối khóa vẫn chỉ có ở §1.3
updatedAtstring (ISO)khôngLần cuối bản ghi khóa học được sửa. ⚠️ Không đổi khi nội dung chủ đề / bài học bên trong đổi — xem §7

Test — GET /api/v1/courses

chưa gọi

1.3 · Chi tiết một khóa học

GET X-API-KEY

/api/v1/courses/{slug}

Mục lục một khóa học: danh sách chủ đề kèm danh sách bài học của từng chủ đề, cộng quiz cuối khóa.

Đây là endpoint "phần khung", không phải endpoint nội dung. Chủ đề ở đây không kèm contentHtml của bài học và không kèm câu hỏi trong bài — một khóa học đầy đủ sẽ là một payload rất lớn. Khi người học mở một chủ đề, gọi /api/v1/topics/{slug} để lấy nội dung thật.

Tham số đường dẫn

Tham sốKiểuBắt buộcGiải thích
slugstringCÓslug khóa học lấy từ /api/v1/courses. Phân biệt chữ hoa/thường. Sai hoặc khóa học đang draft → 404 course_not_found

Response 200

{
  "id": "clx7k2h9d9000abcd1234efgh",
  "slug": "giao-dich-hang-hoa-phai-sinh-co-ban",
  "title": "Giao dịch hàng hóa phái sinh cơ bản",
  "description": "Khóa học nền tảng về giao dịch hàng hóa phái sinh.",
  "coverImageUrl": "https://cdn.example.com/hvhh/khoa-hoc-1.jpg",
  "sortOrder": 0,
  "updatedAt": "2026-08-22T07:30:00.000Z",
  "topics": [
    {
      "id": "clx7k2h9d0000abcd1234efgh",
      "slug": "gioi-thieu-hang-hoa",
      "title": "Giới thiệu hàng hóa",
      "description": "Kiến thức nền tảng về thị trường hàng hóa phái sinh.",
      "coverImageUrl": "https://cdn.example.com/hvhh/anh-bia.jpg",
      "sortOrder": 0,
      "lessonCount": 1,
      "hasEndQuiz": true,
      "lessons": [
        {
          "id": "clx7k2h9d0001abcd1234efgh",
          "slug": "hop-dong-tuong-lai-la-gi",
          "title": "Hợp đồng tương lai là gì?",
          "coverImageUrl": "https://cdn.example.com/hvhh/bai-1.jpg",
          "sortOrder": 0,
          "updatedAt": "2026-08-21T07:30:00.000Z"
        }
      ],
      "updatedAt": "2026-08-21T07:30:00.000Z"
    }
  ],
  "endQuiz": {
    "questions": [
      {
        "id": "clx7k2h9d9005abcd1234efgh",
        "type": "multiple_choice",
        "question": "Chọn các phát biểu đúng về giao dịch hàng hóa phái sinh:",
        "explanation": null,
        "options": [
          { "id": "clx7k2h9d9006abcd1234efgh", "text": "Phát biểu A", "isCorrect": true },
          { "id": "clx7k2h9d9007abcd1234efgh", "text": "Phát biểu B", "isCorrect": false }
        ]
      }
    ]
  }
}

Field cấp khóa học

FieldKiểuNull?Giải thích
id, slug, title, description, coverImageUrl, sortOrder, updatedAt——Giống hệt CourseSummary ở §1.2
topicsarraykhông (có thể [])Chủ đề đã đăng, đã sắp theo sortOrder → thời điểm tạo. [] nếu khóa học chưa có chủ đề nào đăng
endQuizobject | nullcóBài kiểm tra cuối khóa, cấu trúc { questions: [...] } giống endQuiz của chủ đề. null khi không có câu hỏi cuối khóa nào đã đăng — phải kiểm tra null trước khi đọc .questions
Quiz cuối khóa và quiz cuối chủ đề là hai tập câu hỏi khác nhau. Quiz cuối khóa chỉ trả ở endpoint này; quiz cuối chủ đề chỉ trả ở §1.4.

Mỗi phần tử topics[] — đối tượng CourseTopic

FieldKiểuNull?Giải thích
idstring (CUID)khôngKhóa chính bền vững của chủ đề. Dùng làm khóa lưu trữ / đối chiếu ở phía bạn
slugstringkhôngĐịnh danh dạng URL, duy nhất toàn hệ thống (không chỉ trong khóa học), chỉ chữ thường/số/gạch nối (đã bỏ dấu). Dùng để gọi /api/v1/topics/{slug}. Có thể đổi nếu tiêu đề thay đổi
titlestringkhôngTên chủ đề, tiếng Việt có dấu. Hiển thị trực tiếp cho người học
descriptionstringcóMô tả ngắn dạng văn bản thuần (không phải HTML). null khi không có nội dung
coverImageUrlstringcóURL tuyệt đối https:// tới ảnh bìa chủ đề. null khi không có ảnh — phía bạn cần một ảnh mặc định. Ảnh đọc công khai, <img src> trực tiếp được
sortOrdernumber (int)khôngThứ hạng trong phạm vi khóa học này — hai chủ đề ở hai khóa học khác nhau đều có thể là 0. Từ 0, không đảm bảo liên tục và không đảm bảo duy nhất. Chỉ để tham khảo — thứ tự đúng là thứ tự mảng
lessonCountnumber (int)khôngSố bài học đã đăng trong chủ đề (bài draft không tính), nên khớp đúng lessons.length. Có thể là 0
hasEndQuizbooleankhôngtrue nếu chủ đề có ít nhất một câu hỏi đã đăng ở quiz cuối chủ đề. Dùng để hiện nhãn "có bài kiểm tra cuối" mà không cần gọi §1.4
updatedAtstring (ISO)khôngLần cuối bản ghi chủ đề được sửa (tiêu đề, mô tả, ảnh bìa, trạng thái, thứ tự). ⚠️ Không đổi khi nội dung một bài học bên trong đổi — xem §7
lessonsarraykhông (có thể [])Mục lục bài học đã đăng, đã sắp thứ tự. Chỉ có id, slug, title, coverImageUrl, sortOrder, updatedAt — không có contentHtml, không có quizzes

Test — GET /api/v1/courses/{slug}

chưa gọi

Khối "Xem trước mục lục" vẽ cây chủ đề → bài học của khóa học và cho phép làm thử quiz cuối khóa. Bấm nút slug → §1.4 ở một chủ đề để nạp nhanh slug đó vào khối test chi tiết chủ đề.

1.4 · Chi tiết một chủ đề

GET X-API-KEY

/api/v1/topics/{slug}

Toàn bộ nội dung một chủ đề trong một request: tất cả bài học kèm HTML và quiz, cộng quiz cuối chủ đề. Đây là endpoint nên dùng để tải một khoá học.

Tham số đường dẫn

Tham sốKiểuBắt buộcGiải thích
slugstringCÓslug của chủ đề lấy từ /api/v1/courses/{slug}. Phân biệt chữ hoa/thường. Sai, chủ đề đang draft, hoặc khóa học chứa nó đang draft → 404 topic_not_found

Response 200

{
  "id": "clx7k2h9d0000abcd1234efgh",
  "course": {
    "id": "clx7k2h9d9000abcd1234efgh",
    "slug": "giao-dich-hang-hoa-phai-sinh-co-ban",
    "title": "Giao dịch hàng hóa phái sinh cơ bản"
  },
  "slug": "gioi-thieu-hang-hoa",
  "title": "Giới thiệu hàng hóa",
  "description": "Kiến thức nền tảng về thị trường hàng hóa phái sinh.",
  "coverImageUrl": "https://cdn.example.com/hvhh/anh-bia.jpg",
  "sortOrder": 0,
  "updatedAt": "2026-08-21T07:30:00.000Z",
  "lessons": [
    {
      "id": "clx7k2h9d0001abcd1234efgh",
      "slug": "hop-dong-tuong-lai-la-gi",
      "title": "Hợp đồng tương lai là gì?",
      "coverImageUrl": "https://cdn.example.com/hvhh/bai-1.jpg",
      "sortOrder": 0,
      "contentHtml": "<h2>Khái niệm</h2><p>Hợp đồng tương lai là…</p>",
      "quizzes": [
        {
          "id": "clx7k2h9d0002abcd1234efgh",
          "type": "single_choice",
          "question": "Hợp đồng tương lai là gì?",
          "explanation": "Xem lại phần Khái niệm.",
          "options": [
            { "id": "clx7k2h9d0003abcd1234efgh", "text": "Một thoả thuận mua/bán trong tương lai", "isCorrect": true },
            { "id": "clx7k2h9d0004abcd1234efgh", "text": "Một loại cổ phiếu", "isCorrect": false }
          ]
        }
      ],
      "updatedAt": "2026-08-21T07:30:00.000Z"
    }
  ],
  "endQuiz": {
    "questions": [
      {
        "id": "clx7k2h9d0005abcd1234efgh",
        "type": "multiple_choice",
        "question": "Chọn các phát biểu đúng:",
        "explanation": null,
        "options": [
          { "id": "clx7k2h9d0006abcd1234efgh", "text": "Phát biểu A", "isCorrect": true },
          { "id": "clx7k2h9d0007abcd1234efgh", "text": "Phát biểu B", "isCorrect": true },
          { "id": "clx7k2h9d0008abcd1234efgh", "text": "Phát biểu C", "isCorrect": false }
        ]
      }
    ]
  }
}

Field cấp chủ đề

FieldKiểuNull?Giải thích
id, slug, title, description, coverImageUrl, sortOrder, updatedAt——Giống hệt CourseTopic ở §1.3
courseobjectkhôngKhóa học chứa chủ đề này — { id, slug, title }. Mỗi chủ đề luôn thuộc đúng một khóa học nên field này không bao giờ null. Đủ để vẽ breadcrumb / nút "về khóa học" mà không cần gọi thêm request
lessonsarraykhông (có thể [])Bài học đã đăng, đã sắp theo sortOrder → thời điểm tạo. [] nếu chủ đề chưa có bài nào đăng
endQuizobject | nullcóBài kiểm tra cuối chủ đề (không phải cuối khóa — cái đó ở §1.3). null khi chủ đề không có câu hỏi cuối nào ở trạng thái đã đăng — phải kiểm tra null trước khi đọc .questions
endQuiz.questionsarraykhôngMảng Quiz, đã sắp thứ tự. Khi endQuiz khác null thì luôn có ít nhất 1 phần tử
Response chi tiết không có lessonCount và hasEndQuiz (đã có lessons.length và endQuiz !== null), cũng không có createdAt hay status. Cấu trúc endQuiz bọc thêm một lớp { questions: [...] } thay vì trả thẳng mảng, để sau này thêm được thuộc tính cấp bài kiểm tra (điểm đạt, giới hạn thời gian…) mà không phá vỡ hợp đồng.

Mỗi phần tử lessons[] — đối tượng Lesson

FieldKiểuNull?Giải thích
idstringkhôngKhóa chính bền vững của bài học
slugstringkhôngĐịnh danh URL, duy nhất toàn hệ thống (không chỉ trong chủ đề), nên gọi được /api/v1/lessons/{slug} mà không cần biết chủ đề
titlestringkhôngTên bài học, tiếng Việt có dấu
coverImageUrlstringcóẢnh bìa riêng của bài học, URL công khai. null nếu không đặt
sortOrdernumber (int)khôngThứ hạng trong chủ đề, từ 0. Không đảm bảo liên tục/duy nhất — dùng thứ tự mảng
contentHtmlstringkhông (có thể "")Nội dung bài học dạng HTML đã sanitize ở máy chủ trước khi lưu. Chuỗi rỗng nếu bài học chưa có nội dung. Xem §3 để biết đúng những thẻ nào có thể xuất hiện
quizzesarraykhông (có thể [])Câu hỏi đã đăng thuộc riêng bài học này, đã sắp thứ tự. [] nếu bài học không có câu hỏi
updatedAtstring (ISO)khôngLần cuối bản ghi bài học được sửa (tiêu đề, contentHtml, ảnh bìa, trạng thái, thứ tự). ⚠️ Không đổi khi chỉ sửa quiz của bài học đó

Bài học không có field description; phần dẫn nhập nằm trong contentHtml.

Test — GET /api/v1/topics/{slug}

chưa gọi

Khối "Xem trước nội dung" render contentHtml trong <iframe sandbox> kèm CSS gợi ý ở §3, và cho phép làm thử quiz với hàm chấm điểm ở §4.

1.5 · Chi tiết một bài học

GET X-API-KEY

/api/v1/lessons/{slug}

Một bài học đơn lẻ. Dùng khi người học mở sâu vào một bài (deep link) và bạn không muốn tải cả chủ đề.

Tham số đường dẫn

Tham sốKiểuBắt buộcGiải thích
slugstringCÓslug bài học. Trả 404 lesson_not_found nếu: slug không tồn tại, bài học đang draft, hoặc chủ đề / khóa học chứa nó đang draft

Response 200

{
  "id": "clx7k2h9d0001abcd1234efgh",
  "slug": "hop-dong-tuong-lai-la-gi",
  "title": "Hợp đồng tương lai là gì?",
  "coverImageUrl": "https://cdn.example.com/hvhh/bai-1.jpg",
  "sortOrder": 0,
  "contentHtml": "<h2>Khái niệm</h2><p>Hợp đồng tương lai là…</p>",
  "topic": {
    "id": "clx7k2h9d0000abcd1234efgh",
    "slug": "gioi-thieu-hang-hoa",
    "title": "Giới thiệu hàng hóa",
    "course": {
      "id": "clx7k2h9d9000abcd1234efgh",
      "slug": "giao-dich-hang-hoa-phai-sinh-co-ban",
      "title": "Giao dịch hàng hóa phái sinh cơ bản"
    }
  },
  "quizzes": [ … giống lessons[].quizzes ở §1.4 … ],
  "updatedAt": "2026-08-21T07:30:00.000Z"
}

Các field id, slug, title, coverImageUrl, sortOrder, contentHtml, quizzes, updatedAt giống hệt Lesson ở §1.4. Khác biệt duy nhất: có thêm topic — thông tin tối thiểu về chủ đề cha và khóa học của nó, đủ để vẽ trọn breadcrumb Khóa học → Chủ đề → Bài học mà không phải gọi thêm request.

FieldKiểuNull?Giải thích
topic.idstringkhôngid chủ đề cha
topic.slugstringkhôngslug chủ đề cha — dùng để gọi /api/v1/topics/{slug}
topic.titlestringkhôngTên chủ đề cha, để hiển thị
topic.courseobjectkhôngKhóa học chứa chủ đề — { id, slug, title }. Không bao giờ null
Response này không chứa quiz cuối chủ đề hay quiz cuối khóa (endQuiz) — muốn có thì gọi endpoint chủ đề (§1.4) hoặc endpoint khóa học (§1.3).

Test — GET /api/v1/lessons/{slug}

chưa gọi

Gợi ý: chạy test ở §1.4 trước, rồi bấm nút slug → trong khối xem trước để nạp nhanh slug bài học vào đây.

1.6 · Danh sách thử thách

GET X-API-KEY

/api/v1/challenges

Danh sách toàn bộ thử thách đã đăng, đã sắp thứ tự. Thử thách là một bộ câu hỏi độc lập: không thuộc khóa học hay chủ đề nào, không có bài học — chỉ có câu hỏi. Dùng cho màn hình "chọn thử thách" tách khỏi phần khóa học.

Response 200

{
  "data": [
    {
      "id": "clx9m1a2b0000abcd1234efgh",
      "slug": "thu-thach-kien-thuc-hang-hoa",
      "title": "Thử thách kiến thức hàng hóa",
      "description": "Bộ câu hỏi tổng hợp toàn bộ kiến thức nền tảng.",
      "coverImageUrl": "https://cdn.example.com/hvhh/thu-thach-1.jpg",
      "sortOrder": 0,
      "questionCount": 50,
      "updatedAt": "2026-09-15T07:30:00.000Z"
    }
  ],
  "total": 1
}

Field cấp ngoài

FieldKiểuNull?Giải thích
dataarraykhôngMảng thử thách, đã sắp theo sortOrder tăng dần. Rỗng nếu chưa đăng thử thách nào
totalnumberkhôngSố phần tử trong data. Không phân trang nên luôn bằng data.length

Mỗi phần tử trong data — đối tượng ChallengeSummary

FieldKiểuNull?Giải thích
idstringkhôngKhóa chính bền vững. Lưu theo field này, không lưu theo slug
slugstringkhôngĐịnh danh dạng URL, duy nhất toàn hệ thống. Dùng để gọi §1.7. Có thể đổi nếu tiêu đề thay đổi
titlestringkhôngTên thử thách, tiếng Việt có dấu
descriptionstringcóMô tả ngắn dạng văn bản thuần (không phải HTML). null khi để trống
coverImageUrlstringcóURL tuyệt đối https:// tới ảnh bìa. null khi không có ảnh
sortOrdernumber (int)khôngThứ hạng sắp xếp, từ 0. Thứ tự đúng là thứ tự mảng
questionCountnumber (int)khôngSố câu hỏi đã đăng. Câu hỏi còn draft không được tính và cũng không trả về ở §1.7. Có thể là 0
updatedAtstring (ISO)khôngLần cuối bản ghi thử thách được sửa. ⚠️ Không đổi khi câu hỏi bên trong đổi — xem §7
Thử thách chưa có câu hỏi đã đăng nào vẫn xuất hiện ở đây với questionCount: 0. Nếu muốn ẩn khỏi giao diện thì tự lọc bỏ các phần tử questionCount === 0 ở phía bạn.

Test — GET /api/v1/challenges

chưa gọi

1.7 · Bộ câu hỏi của một thử thách

GET X-API-KEY

/api/v1/challenges/{slug}

Bộ câu hỏi của một thử thách. Đây là endpoint duy nhất của API có tham số query — mọi endpoint khác bỏ qua query string hoàn toàn.

Tham số đường dẫn

Tham sốKiểuGiải thích
slugstringslug thử thách. Trả 404 challenge_not_found nếu slug không tồn tại hoặc thử thách đang draft

Tham số query

Tham sốKiểuMặc địnhGiải thích
modefull | randomfullfull: trả toàn bộ câu hỏi đã đăng, đúng thứ tự đã sắp. random: trả cùng bộ câu hỏi đó nhưng đã xáo trộn ngẫu nhiên. Giá trị khác → 400 invalid_mode
limitnumber (int ≥ 1)(không giới hạn)Số câu tối đa trả về. Chỉ áp dụng cho mode=random — ở mode=full tham số này bị bỏ qua. Không truyền thì random vẫn trả hết, chỉ khác thứ tự. limit lớn hơn số câu hiện có thì trả hết, không báo lỗi. Giá trị không phải số nguyên dương → 400 invalid_limit
# Toàn bộ bộ câu hỏi, đúng thứ tự đã soạn
curl -H "X-API-Key: hvhh_xxxxxxxx" \
  "https://hvhh.sfi.com.vn/api/v1/challenges/thu-thach-kien-thuc-hang-hoa"

# Bốc ngẫu nhiên 10 câu cho một lượt chơi
curl -H "X-API-Key: hvhh_xxxxxxxx" \
  "https://hvhh.sfi.com.vn/api/v1/challenges/thu-thach-kien-thuc-hang-hoa?mode=random&limit=10"

Response 200

{
  "id": "clx9m1a2b0000abcd1234efgh",
  "slug": "thu-thach-kien-thuc-hang-hoa",
  "title": "Thử thách kiến thức hàng hóa",
  "description": "Bộ câu hỏi tổng hợp toàn bộ kiến thức nền tảng.",
  "coverImageUrl": "https://cdn.example.com/hvhh/thu-thach-1.jpg",
  "sortOrder": 0,
  "updatedAt": "2026-09-15T07:30:00.000Z",
  "mode": "random",
  "totalQuestions": 50,
  "returnedQuestions": 10,
  "questions": [
    {
      "id": "clx9m1a2b0001abcd1234efgh",
      "type": "single_choice",
      "question": "Ký quỹ ban đầu là gì?",
      "explanation": "Xem lại phần Ký quỹ.",
      "options": [
        { "id": "clx9m1a2b0002abcd1234efgh", "text": "Khoản đặt cọc để mở vị thế", "isCorrect": true },
        { "id": "clx9m1a2b0003abcd1234efgh", "text": "Phí giao dịch", "isCorrect": false }
      ]
    }
  ]
}

Các field id, slug, title, description, coverImageUrl, sortOrder, updatedAt giống hệt ChallengeSummary ở §1.6. Bốn field còn lại:

FieldKiểuNull?Giải thích
modefull | randomkhôngChế độ đã thực sự áp dụng — phản chiếu lại tham số query, tiện cho log và debug
totalQuestionsnumber (int)khôngTổng số câu hỏi đã đăng của thử thách, không phụ thuộc limit. Dùng để hiện "10 / 50 câu"
returnedQuestionsnumber (int)khôngSố câu trong questions[] của chính response này. Ở mode=full luôn bằng totalQuestions; ở mode=random bằng min(limit, totalQuestions)
questionsarraykhôngCác câu hỏi, cấu trúc Quiz ở §2. [] nếu thử thách chưa có câu hỏi nào đã đăng
mode=random không được cache. Response trả Cache-Control: no-store và X-Cache: BYPASS, không có ETag. Mỗi lượt gọi là một lần bốc mới — hai request giống hệt nhau vẫn cho hai bộ câu hỏi khác nhau, nên đừng gửi If-None-Match cho chế độ này và đừng cache lại kết quả ở phía bạn nếu muốn mỗi lượt chơi một đề khác. mode=full (kể cả khi không truyền mode) vẫn cache và có ETag như các endpoint còn lại.
Xáo trộn chạy trên toàn bộ bộ câu hỏi rồi mới cắt theo limit, nên mỗi câu hỏi có xác suất được chọn như nhau và không bao giờ trùng trong cùng một response. Đáp án bên trong mỗi câu không bị xáo — thứ tự options[] luôn theo đúng thứ tự đã soạn; muốn xáo đáp án thì tự làm ở phía bạn.

Test — GET /api/v1/challenges/{slug}

chưa gọi

Gợi ý: đặt mode=random + limit=5 rồi bấm Gửi request vài lần — dòng tóm tắt cho biết bộ câu vừa bốc có khác lượt trước hay không, tức là random đang chạy thật chứ không bị cache.

2

Đối tượng dùng chung: Quiz và QuizOption

Dùng ở cả năm chỗ: lessons[].quizzes[], endQuiz.questions[] của chủ đề, endQuiz.questions[] của khóa học, quizzes[] của endpoint bài học, và questions[] của thử thách (§1.7). Cấu trúc hoàn toàn giống nhau ở cả năm chỗ — viết một hàm render dùng chung là đủ.

Quiz — một câu hỏi

FieldKiểuNull?Giải thích
idstringkhôngKhóa chính của câu hỏi. Dùng làm khóa khi lưu câu trả lời của người học ở phía bạn
type"single_choice" | "multiple_choice"khôngsingle_choice: đúng một đáp án đúng → radio. multiple_choice: một hoặc nhiều đáp án đúng → checkbox. Đây là hai giá trị duy nhất; hãy xử lý giá trị lạ một cách an toàn (mặc định về checkbox) để API thêm loại mới không làm hỏng client
questionstringkhôngNội dung câu hỏi, văn bản thuần (không phải HTML). Có thể dài nhiều dòng — hiển thị bằng white-space: pre-wrap nếu muốn giữ ngắt dòng. Không dùng v-html/innerHTML cho field này
explanationstringcóGiải thích đáp án, văn bản thuần. null khi không có nội dung. Thường chỉ hiện sau khi người học đã trả lời
optionsarraykhôngCác lựa chọn, đã sắp theo sortOrder tăng dần. Có thể rỗng ([]) nếu câu hỏi chưa có lựa chọn — hãy bỏ qua câu hỏi có options.length === 0 thay vì render một câu hỏi không trả lời được

QuizOption — một lựa chọn

FieldKiểuNull?Giải thích
idstringkhôngKhóa chính của lựa chọn. Đây là giá trị nên gửi/lưu khi ghi nhận người học chọn gì — đừng dùng chỉ số mảng (thứ tự có thể đổi) hay text
textstringkhôngNội dung lựa chọn, văn bản thuần
isCorrectbooleankhôngtrue nếu là đáp án đúng. Được trả về có chủ ý để bên thứ ba tự chấm điểm — kèm theo đó là hệ quả bảo mật ở §4

QuizOption không trả sortOrder (thứ tự mảng đã là thứ tự đúng) và không trả quizId (đã nằm lồng trong quiz).

3

contentHtml — đặc tả và cách render

contentHtml là HTML nội dung bài học, đã được làm sạch (sanitize) ở máy chủ trước khi lưu — vì nội dung này phân phối cho nhiều bên thứ ba, việc làm sạch được thực hiện một lần ở nguồn thay vì tin rằng mọi bên tiêu thụ đều tự làm sạch. Đây là allowlist: bất cứ thứ gì không có trong danh sách dưới đây đã bị loại bỏ, nên bạn biết chắc chuỗi này không bao giờ chứa <script>, <style>, <form>, onclick, javascript: hay data: URI.

Thẻ có thể xuất hiện

NhómThẻ
Khối văn bảnp br hr div span blockquote pre code
Tiêu đềh1 h2 h3 h4
Định dạng chữstrong b em i u s sub sup
Danh sáchul ol li
Liên kếta
Mediaimg video source iframe
Bảngtable thead tbody tr td th colgroup col

Thuộc tính có thể xuất hiện

ThẻThuộc tínhGhi chú
ahref title target relSanitizer luôn ép target="_blank" và rel="noopener noreferrer", kể cả link nội bộ
imgsrc alt title width height data-width data-alignsrc luôn là http/https tuyệt đối (thường là R2). alt có thể rỗng
videosrc controls poster width height data-width data-align data-video-typeVideo tải trực tiếp qua URL trong src
sourcesrc typeNằm trong video
iframesrc width height allow allowfullscreen title data-width data-align data-video-typeVideo nhúng — chỉ https và chỉ các host ở bảng dưới
td / thcolspan rowspan colwidthcolwidth: danh sách số cách nhau bởi dấu phẩy (đơn vị px), do trình soạn thảo sinh ra
colwidth style—
Mọi thẻstyleChỉ các thuộc tính CSS ở bảng dưới

Host được phép trong iframe src

www.youtube.com · youtube.com · www.youtube-nocookie.com · youtube-nocookie.com · player.vimeo.com

Mọi host khác đã bị loại bỏ từ lúc lưu, nên bạn có thể render iframe mà không cần kiểm tra lại host. iframe bắt buộc https, không cho phép URL tương đối.

Thuộc tính CSS được phép trong style

Thuộc tínhGiá trị hợp lệ
text-alignleft right center justify
displayblock inline-block
width, max-width<số>px hoặc <số>%
heightauto, <số>px, <số>%
aspect-ratio<số> / <số> (ví dụ 16 / 9)
border0 hoặc none
marginchuỗi gồm chữ/số/./%/-/khoảng trắng
margin-left, margin-rightauto, 0, <số>px (đây là cách editor căn giữa/căn lề media)

Các data-* (data-width, data-align, data-video-type) là siêu dữ liệu của trình soạn thảo. Bố cục thật đã nằm trong style inline, nên bạn có thể bỏ qua data-* — hoặc dùng data-align nếu muốn tự áp CSS thay vì dựa vào inline style.

CSS tối thiểu để render tử tế

HTML này không kèm class nào, nên nó thừa hưởng CSS của trang bạn. Nếu trang dùng Tailwind Preflight (hoặc bất kỳ CSS reset nào), h2/ul/table sẽ mất hết định dạng mặc định. Hãy bọc nội dung trong một container và áp style cho các thẻ con — đây chính là CSS mà khối "Xem trước nội dung" ở §1.4 / §1.5 đang dùng:

.hvhh-content { line-height: 1.7; }
.hvhh-content h1 { font-size: 1.5rem;  font-weight: 700; margin: 1.5em 0 .5em; }
.hvhh-content h2 { font-size: 1.25rem; font-weight: 700; margin: 1.5em 0 .5em; }
.hvhh-content h3 { font-size: 1.1rem;  font-weight: 600; margin: 1.25em 0 .5em; }
.hvhh-content p  { margin: .75em 0; }
.hvhh-content ul { list-style: disc;    padding-left: 1.5rem; margin: .75em 0; }
.hvhh-content ol { list-style: decimal; padding-left: 1.5rem; margin: .75em 0; }
.hvhh-content a  { color: #e00303; text-decoration: underline; }
.hvhh-content blockquote { border-left: 3px solid #e5e7eb; padding-left: 1rem; color: #475569; }
.hvhh-content pre  { background: #f1f5f9; padding: .75rem; border-radius: .5rem; overflow-x: auto; }
.hvhh-content code { font-family: ui-monospace, monospace; font-size: .9em; }

/* Media: chặn tràn khung trên mobile — inline style có thể đặt width cố định bằng px */
.hvhh-content :is(img, video, iframe) { max-width: 100%; height: auto; }
.hvhh-content iframe { aspect-ratio: 16 / 9; width: 100%; border: 0; }

/* Bảng: nội dung có bảng rộng, đừng để nó đẩy giãn cả trang */
.hvhh-content table { border-collapse: collapse; width: 100%; }
.hvhh-content :is(td, th) { border: 1px solid #e2e8f0; padding: .5rem; }

Bảng rộng nên bọc thêm một div có overflow-x: auto. Nếu dùng Tailwind Typography thì class="prose max-w-none" xử lý hầu hết phần trên, chỉ cần thêm quy tắc cho iframe.

Nếu vẫn muốn sanitize lại ở phía mình

Chính đáng — phòng thủ nhiều tầng. Nhưng cấu hình mặc định của DOMPurify loại bỏ <iframe>, nên video YouTube/Vimeo trong bài học sẽ biến mất. Hãy khai báo tường minh:

import DOMPurify from 'dompurify'

const html = DOMPurify.sanitize(lesson.contentHtml, {
  ADD_TAGS: ['iframe'],
  ADD_ATTR: ['allow', 'allowfullscreen', 'target', 'colwidth',
             'data-width', 'data-align', 'data-video-type'],
})
4

Chấm điểm quiz

Đáp án đúng (isCorrect) nằm ngay trong response, theo đúng thiết kế: bên thứ ba tự chấm điểm mà không cần thêm endpoint nào.

/**
 * @param quiz     một đối tượng Quiz từ API
 * @param selected mảng id lựa chọn người học đã chọn
 */
function isAnswerCorrect(quiz, selected) {
  const correct = quiz.options.filter((o) => o.isCorrect).map((o) => o.id)
  const picked = [...new Set(selected)]

  if (quiz.type === 'single_choice') {
    // Đúng một đáp án đúng, và người học chỉ được chọn một.
    return picked.length === 1 && correct.includes(picked[0])
  }
  // multiple_choice: phải chọn đủ và không chọn thừa.
  return picked.length === correct.length && correct.every((id) => picked.includes(id))
}

Quy ước cần thống nhất trước khi làm giao diện:

  • single_choice luôn có đúng một isCorrect: true (bảo đảm từ phía SFI khi tạo nội dung).
  • multiple_choice có từ một isCorrect: true trở lên — không nhất thiết phải ≥ 2.
  • Không có điểm từng phần trong dữ liệu. Muốn chấm nửa điểm cho câu chọn đúng một phần thì đó là quy tắc phía bạn tự định nghĩa.
  • Không có "điểm đạt" (passingScore) trong dữ liệu. Ngưỡng đạt do phía bạn quyết định.
Đáp án lộ ở phía client. Nếu bạn gọi API này từ trình duyệt rồi render quiz, người học chỉ cần mở tab Network là thấy isCorrect — đúng như trang test này đang làm. Với bài học tự luyện thì không sao. Nếu cần một kỳ kiểm tra có kết quả đáng tin (chứng chỉ, KPI đào tạo…), phải: gọi API từ máy chủ của bạn → lược bỏ isCorrect trước khi trả về trình duyệt → chấm điểm ở máy chủ.
Hàm isAnswerCorrect() ở trên được nhúng nguyên văn vào trang này và dùng để chấm phần làm thử quiz trong các khối "Xem trước" — bạn có thể đối chiếu hành vi thực tế ngay tại §1.3 (quiz cuối khóa) và §1.4.
5

Bảng mã lỗi

Mọi lỗi của /api/v1/* trả JSON dạng { "error": "<thông báo tiếng Việt>", "code": "<mã ổn định>" }. Hãy dựa vào code để phân nhánh xử lý; error là câu tiếng Việt có thể đổi cách diễn đạt.

HTTPcodeerrorNguyên nhânNên làm gì
401missing_api_keyThiếu header X-API-Key.Không gửi header, hoặc gửi rỗngLỗi cấu hình phía bạn — đừng thử lại, hãy báo lỗi triển khai
401invalid_api_keyAPI key không hợp lệ.Key sai, gõ nhầm, hoặc đã bị xoá khỏi hệ thốngĐừng thử lại. Kiểm tra biến môi trường, liên hệ SFI xin key mới
403revoked_api_keyAPI key đã bị thu hồi.Key còn tồn tại nhưng đã bị thu hồiĐừng thử lại. Liên hệ SFI
404course_not_foundKhông tìm thấy khóa học.Slug không tồn tại, hoặc khóa học đang draftVới đồng bộ định kỳ: coi như khóa học đã bị ẩn, xoá khỏi cache phía bạn
404topic_not_foundKhông tìm thấy chủ đề.Slug không tồn tại, chủ đề đang draft, hoặc khóa học cha đang draftNhư trên
404lesson_not_foundKhông tìm thấy bài học.Slug không tồn tại, bài học đang draft, hoặc chủ đề / khóa học cha đang draftNhư trên
404challenge_not_foundKhông tìm thấy thử thách.Slug không tồn tại, hoặc thử thách đang draftNhư trên
400invalid_modeTham số mode chỉ nhận giá trị "full" hoặc "random".?mode= nhận giá trị lạ (§1.7)Lỗi phía bạn — sửa URL, đừng thử lại
400invalid_limitTham số limit phải là số nguyên lớn hơn 0.?limit= không phải số nguyên ≥ 1 (§1.7)Như trên
404(không có code)Not foundĐường dẫn không thuộc API — sai path, sai method, thiếu /v1Sửa URL. Lưu ý response này không kiểm tra API key
500(không có code)Thông báo lỗi chung của máy chủSự cố tạm thời phía máy chủThử lại với backoff luỹ tiến

Không có mã lỗi nào cho rate limit (429) vì hiện chưa có giới hạn tần suất.

Test — thử các nhánh lỗi

Mỗi nút gửi một request "sai" có chủ ý để bạn thấy đúng payload lỗi tương ứng.

chưa gọi
6

Trường hợp biên

Tình huốngAPI trả về
Chưa đăng khóa học / chủ đề nào200 với { "data": [], "total": 0 } — không phải 404
Khóa học đã đăng nhưng chưa có chủ đề nào đăng200, topics: [], topicCount: 0
Chủ đề đã đăng nhưng chưa có bài học nào đăng200, lessons: [], lessonCount: 0
Chủ đề / khóa học không có quiz cuốiendQuiz: null
Quiz cuối có câu hỏi nhưng tất cả đang draftendQuiz: null và hasEndQuiz: false
Bài học chưa có nội dungcontentHtml: "" (chuỗi rỗng, không phải null)
Bài học không có câu hỏiquizzes: []
Câu hỏi không có lựa chọn nàooptions: [] — hãy bỏ qua câu hỏi này khi render
Bài học đã đăng nhưng chủ đề cha draft404 lesson_not_found — bài học "biến mất" theo chủ đề
Chủ đề đã đăng nhưng khóa học cha draft404 topic_not_found. Bài học của nó cũng trả 404 — gỡ đăng một khóa học ẩn toàn bộ nhánh bên dưới
Chuyển một chủ đề sang khóa học khácid và slug chủ đề không đổi; course trong response trỏ sang khóa học mới, và chủ đề nằm cuối topics[] của khóa học đó
Một chủ đề đang được đối tác dùng bị gỡ đăngRequest tiếp theo trả 404, và chủ đề mất khỏi topics[] của khóa học. Không có cơ chế thông báo trước
Tiêu đề thay đổi khiến slug đổiSlug cũ trả 404, slug mới xuất hiện trong response của khóa học. id không đổi — đây là lý do nên lưu theo id
Chưa đăng thử thách nào200 với { "data": [], "total": 0 }
Thử thách đã đăng nhưng mọi câu hỏi đang draft200, questions: [], totalQuestions: 0, questionCount: 0 — không phải 404
?limit= lớn hơn số câu hiện cóTrả hết số câu đang có, không báo lỗi — returnedQuestions nhỏ hơn limit
?limit= ở mode=fullBị bỏ qua — full luôn trả toàn bộ
Gọi ?mode=random hai lần liên tiếpHai bộ câu khác nhau. Nếu nhận được bộ giống hệt thì có cache nằm giữa bạn và API — xem §1.7
Gửi query string bất kỳBị bỏ qua, nhưng tạo thêm một entry cache riêng. Ngoại lệ: mode / limit ở §1.7 là tham số thật
Gọi POST / PUT / DELETE404 {"error":"Not found"}
7

Phát hiện nội dung mới (đồng bộ)

API không có webhook và không có endpoint "cái gì đã đổi từ lúc X". Ba cách, từ nhẹ tới nặng:

CáchCơ chếChi phíHạn chế
ETag + If-None-MatchPoll endpoint bạn cần, 304 = chưa đổiThấp nhất (body rỗng khi không đổi)Chỉ biết endpoint đó có đổi; JS chạy trong trình duyệt không đọc được ETag nên cách này phải làm ở máy chủ
GET /api/healthcontentVersion tăng khi bất kỳ nội dung nào đổiRất thấp, không cần API keyKhông biết cái gì đổi → phải tải lại phần cần dùng. Reset về 1 khi service restart nên phải coi "số khác lần trước" là có đổi, chứ không phải "số lớn hơn"
updatedAtSo với mốc bạn lưuPhải tải danh sách/chi tiết mới so đượcXem cảnh báo ngay dưới
updatedAt không lan truyền theo cây nội dung. Nó là mốc sửa của chính bản ghi đó. Sửa contentHtml một bài học không làm updatedAt của chủ đề đổi; sửa một câu hỏi không làm updatedAt của bài học đổi (quiz không trả updatedAt). Điều này đúng ở cả cấp khóa học: sửa một bài học không làm updatedAt của khóa học đổi. Vì vậy: đừng dùng courses[].updatedAt / topics[].updatedAt để quyết định có tải lại chi tiết hay không — sẽ bỏ sót thay đổi. Hãy dùng ETag của chính endpoint chi tiết, hoặc contentVersion của /api/health.

Mẫu đồng bộ gợi ý cho một hệ thống có máy chủ riêng

  1. Mỗi 1–5 phút gọi GET /api/health. Nếu contentVersion khác lần trước → sang bước 2.
  2. Gọi GET /api/v1/courses (kèm If-None-Match), rồi từng GET /api/v1/courses/{slug} để lấy mục lục + quiz cuối khóa, rồi từng GET /api/v1/topics/{slug} (kèm If-None-Match riêng cho từng slug) để lấy nội dung thật. Chỉ những endpoint trả 200 mới cần ghi lại.
  3. Nếu có dùng thử thách: gọi thêm GET /api/v1/challenges rồi từng GET /api/v1/challenges/{slug} ở mode=full (kèm If-None-Match) — mode=random không có ETag nên không dùng để đồng bộ được.
  4. Slug từng có nhưng nay mất khỏi danh sách → đánh dấu ẩn ở phía bạn (đừng xoá hẳn nếu còn dữ liệu tiến độ người học tham chiếu tới nó).

Vì mặc định Cache-Control: max-age=0, must-revalidate, poll thường xuyên không tạo tải đáng kể ở phía Học viện hàng hóa (đã có cache ở máy chủ) — nhưng vẫn tốn một round-trip mạng, nên đừng poll mỗi giây.

Lưu ý /api/health không bật CORS, nên vòng poll ở bước 1 phải chạy ở máy chủ của bạn, không chạy được trong trình duyệt — xem §1.1.
8

Những gì API không có

Nói rõ để bạn không mất thời gian đi tìm:

  • Không phân trang, không lọc, không tìm kiếm, không sắp xếp theo tham số. Danh sách trả về toàn bộ; lọc/tìm kiếm làm ở phía bạn. Hai tham số mode / limit ở §1.7 chỉ để bốc ngẫu nhiên câu hỏi của một thử thách, không phải phân trang — không có con trỏ để lấy "trang sau".
  • Không có endpoint danh sách phẳng. Không có danh sách chủ đề hay bài học độc lập với khóa học — muốn toàn bộ cây thì lặp qua /api/v1/courses → /api/v1/courses/{slug} → /api/v1/topics/{slug}.
  • Không có endpoint quiz riêng. Quiz chỉ đến kèm bài học, chủ đề, khóa học hoặc thử thách — câu hỏi của thử thách luôn đến theo cả bộ (§1.7).
  • Không có endpoint thư viện media. Bên thứ ba chỉ thấy các URL đã nhúng trong contentHtml / coverImageUrl.
  • Không ghi. Không tạo/sửa/xoá nội dung, không gửi kết quả làm bài lên hệ thống.
  • Không lưu tiến độ hay điểm của người học. Học viện hàng hóa không biết người học là ai; tiến độ, điểm số, chứng chỉ do phía bạn lưu (khoá theo id của khóa học/chủ đề/bài học/quiz).
  • Không có nội dung draft. Không có tham số nào để xem bản nháp.
  • Không đa ngôn ngữ. Nội dung chỉ tiếng Việt, không có field locale.
  • Không webhook. Xem §7.
  • Không có createdAt trong response nội dung (chỉ có updatedAt).
9

Bảo mật và vận hành

Giữ API key ở phía máy chủ. Key nhúng trong bundle JavaScript hay ứng dụng mobile là key công khai — bất kỳ ai cũng đọc được bằng DevTools hoặc giải nén app. Nếu buộc phải gọi từ trình duyệt, hãy hiểu rõ là bạn đang công khai toàn bộ nội dung học tập kèm đáp án, và chỉ chấp nhận điều đó nếu nội dung vốn được coi là công khai.

Danh sách kiểm tra khi tích hợp

  • Key nằm trong biến môi trường / secret store, không commit vào repo.
  • Mỗi hệ thống tiêu thụ một key riêng, đặt tên rõ ràng lúc tạo để biết đường thu hồi.
  • Có xử lý 401/403: dừng gọi, ghi log, cảnh báo vận hành — không thử lại vòng lặp.
  • Có xử lý 404 khi nội dung bị gỡ đăng, không để giao diện hỏng.
  • Kiểm tra endQuiz !== null (ở cả cấp chủ đề và cấp khóa học) và options.length > 0 trước khi render.
  • Giữ nguyên thứ tự mảng, khoá dữ liệu theo id (không theo slug, không theo chỉ số).
  • Nếu chấm điểm cần đáng tin: lược isCorrect ở tầng máy chủ của bạn (§4).
  • Nếu render trong trình duyệt: có CSS cho contentHtml (§3) và cho phép iframe nếu sanitize lại.

Xoay key: tạo key mới → cập nhật cấu hình phía tiêu thụ → xác nhận key mới đã hoạt động → thu hồi key cũ. Vì key mới cấp ngay lập tức có hiệu lực, không có thời gian chết.

Liên hệ: mọi việc cấp mới, xoay và thu hồi API key do đội Học viện hàng hóa — Saigon Futures thực hiện. Liên hệ đầu mối kỹ thuật đã trao đổi khi bắt đầu tích hợp.

Về chính trang test này. Key bạn nhập ở khối Cấu hình được lưu trong localStorage của trình duyệt và gửi kèm mọi request test. Đây là công cụ dành cho developer trên máy cá nhân — đừng mở trang này trên máy dùng chung, và nên bấm Xoá key khi xong.