TÍCH HỢP TRAVELQ ID
Một ID cho mọi dịch vụ TravelQ. Ứng dụng TravelQ dùng API này để cho du khách lập ID ngay trong app; các trang dịch vụ (khách sạn, nhà hàng, vé, hộ chiếu du lịch…) dùng API key để xác minh ID và ghi nhận giao dịch.
TỔNG QUAN
Địa chỉ gốc: {{BASE}}/api/v1 · Mô tả máy đọc được: /openapi.yaml (OpenAPI 3).
TravelQ ID (Mã định danh du lịch toàn cầu) có dạng TQ + 9 chữ số, VD TQ482019379: số đầu 1–9, 7 số ngẫu nhiên, số cuối là số kiểm tra Luhn. ID không theo thứ tự, không suy ra được thông tin cá nhân. API, QR (TQID:TQ482019379) và sao chép luôn dùng dạng liền; thẻ có thể hiển thị cách quãng TQ 482 019 379 cho dễ đọc. API nhận cả có/không khoảng trắng, gạch. ID cấp trước đây theo dạng cũ TQ-YYYY-XXXXXXXC vẫn tra cứu, xác minh bình thường.
| Ai gọi | Xác thực | Nhóm endpoint |
|---|---|---|
| Ứng dụng TravelQ (iOS/Android) | Authorization: Bearer <accessToken> + refresh token | /auth/*, /me/* |
| Trang web TravelQ ID | Cookie HttpOnly + header X-CSRF-Token | /auth/*, /me/* |
| Máy chủ dịch vụ TravelQ | X-API-Key: tqk_… (mỗi trang một key) | /partner/* |
| Ứng dụng bất kỳ dùng “Đăng nhập bằng TravelQ ID” | OAuth 2.0 / OpenID Connect – code + PKCE, client_id tqc_… | /oauth/*, /.well-known/* |
Lớp tài khoản dùng chung: mỗi người có một tài khoản lõi (mã bất biến sub, dạng u_…) và một TravelQ ID. Các dịch vụ (khách sạn, nhà hàng, vé, ứng dụng TravelQ) giữ hồ sơ dịch vụ riêng của mình và liên kết về sub; lõi chỉ giữ dữ liệu định danh. Mỗi ứng dụng chỉ thấy dữ liệu trong phạm vi người dùng cho phép, và người dùng ngắt kết nối được bất cứ lúc nào.
Phản hồi lỗi luôn có dạng {"error":{"code":"…","message":"…","errors":{…}}}; message bằng tiếng Việt, có thể hiển thị thẳng cho người dùng; errors chỉ ra từng trường sai.
HƯỚNG DẪN TÍCH HỢP CHO APP TRAVELQ
Có 2 cách. Cách 1 (khuyên dùng): app tự dựng giao diện, gọi API JSON. Cách 2: app mở trang /embed/ trong webview, nhận ID qua postMessage hoặc deep link travelq://id/issued?tqid=….
Luôn gửi "client":"app" và "deviceName" khi đăng ký/đăng nhập để nhận token thay vì cookie. Tên thiết bị được lưu cùng phiên đăng nhập (xem qua GET /me/sessions).
Trình tự đầy đủ (Cách 1)
- Lấy danh mục cho các ô chọn (tỉnh/thành, quốc tịch, sở thích, ngôn ngữ, nội dung đồng ý) – nên lưu đệm.
curl {{BASE}}/api/v1/meta{ "version":"1.1.0", "demo":true, "otpMode":"simulated", "idFormat":"TQ#########","idFormatLegacy":"TQ-YYYY-XXXXXXXC", "kinds":["Công dân Việt Nam","Người nước ngoài"], "provinces":["Hà Nội","Huế", "…", "Ở nước ngoài"], "countries":[{"code":"VN","name":"Việt Nam"}, "…"], "genders":[{"key":"Nam","label":"Nam"},{"key":"Nữ","label":"Nữ"},{"key":"Khác","label":"Khác"}], "docTypes":[{"key":"cccd","label":"Căn cước / CCCD"},{"key":"passport","label":"Hộ chiếu"}], "interests":[{"key":"bien-dao","label":"Biển đảo"}, "…"], "languages":[{"key":"vi","label":"Tiếng Việt"}, "…"], "steps":["personal","document","contact","preferences","review"], "consent":{"version":"2026-10-v3","text":"Tôi đồng ý …","purposes":[{"key":"terms","required":true,…},{"key":"travel","required":true,…},{"key":"share","required":false,…}]}, "loginMethods":{"password":true,"otp":true,"gmail":"simulated","demo":true} } - Gửi OTP tới SĐT/email. Ở bản thử nghiệm mã được trả về trong
demoCode(mô phỏng); khi gắn nhà cung cấp SMS/Zalo thật, trường này không còn.curl -X POST {{BASE}}/api/v1/auth/otp/request -H 'Content-Type: application/json' \ -d '{"login":"0912345678","purpose":"register"}' → 200 {"sent":true,"channel":"sms","expiresIn":300,"simulated":true,"demoCode":"482913"} - Đăng ký (hoặc đăng nhập nếu đã có tài khoản) – nhận token.
curl -X POST {{BASE}}/api/v1/auth/register -H 'Content-Type: application/json' \ -d '{"login":"0912345678","password":"Matkhau2026","otp":"482913","client":"app","deviceName":"iPhone của An"}' → 201 {"ok":true,"user":{"login":"0912345678","loginType":"phone","tqid":null}, "tokenType":"Bearer","accessToken":"tqa_…","expiresIn":900, "refreshToken":"tqr_…","refreshExpiresIn":2592000} curl -X POST {{BASE}}/api/v1/auth/login -H 'Content-Type: application/json' \ -d '{"login":"0912345678","password":"Matkhau2026","client":"app","deviceName":"Galaxy S22 Ultra"}' → 200 {… "user":{"tqid":"TQ482019379"} …} // tqid khác null ⇒ đã có ID, bỏ qua bước 4–6 - Cách đăng nhập khác: không cần mật khẩu – xác minh OTP là vào, tài khoản tự tạo nếu chưa có.
// SĐT hoặc Gmail: gửi OTP với purpose "login", rồi xác minh POST /auth/otp/request {"login":"an.nguyen@gmail.com","purpose":"login"} POST /auth/otp/verify {"login":"an.nguyen@gmail.com","otp":"482913","via":"gmail","client":"app","deviceName":"…"} → 201 (tài khoản mới) | 200 (đã có) {"user":{"sub":"u_…","tqid":null|"TQ-…"},"accessToken":"tqa_…","refreshToken":"tqr_…",…} // Google thật (khi đặt GOOGLE_CLIENT_ID): gửi Google ID token đã lấy ở app POST /auth/google {"idToken":"eyJ…","client":"app"} // chưa cấu hình → 501 google_not_configured // Bản thử nghiệm (TQID_DEMO=1): tài khoản demo dùng chung, dữ liệu hư cấu POST /auth/demo {"client":"app"} // Quét CCCD/VNeID MÔ PHỎNG – trả dữ liệu mẫu để điền nhanh, chưa kết nối VNeID POST /me/wizard/scan → {"simulated":true,"data":{"kind":…,"name":…,"dob":…,"cccd":…,"street":…,"ward":…,"prov":…}} - Xem tiến độ bộ lập ID (để tiếp tục nếu người dùng bỏ dở).
curl {{BASE}}/api/v1/me/wizard -H "Authorization: Bearer $ACCESS" → 200 {"issued":false,"step":"personal","steps":["personal","document","contact","preferences","review"], "completed":[],"data":{},"prefill":{"phone":"0912345678"}} - Lưu 7 trường trong một lần (cách app đang dùng – trang
/cap-nhat/): Họ tên, Năm sinh, Số CCCD/Số hộ chiếu, Địa chỉ, Số điện thoại, Tỉnh/Thành phố, Quốc gia. Thành công →step=review; sai → 422 kèmerrorstheo từng trường.// Công dân Việt Nam: quốc gia luôn là VN, tỉnh/thành thuộc 34 tỉnh/thành, CCCD 12 số curl -X PUT {{BASE}}/api/v1/me/wizard/basic -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d '{"kind":"Công dân Việt Nam","name":"Nguyễn Minh An","birthYear":1995,"cccd":"000000000201", "street":"12 Lê Lợi, Phường Hải Châu","phone":"0912345678","prov":"Đà Nẵng","country":"VN"}' → 200 {"ok":true,"step":"review","data":{…,"document":{"docType":"cccd","cccdMasked":"001•••••345"}}} // Khách quốc tế (kind "Người nước ngoài"): quốc gia theo mã ISO-3166 alpha-2, tỉnh/thành tự nhập, hộ chiếu chữ + số, SĐT kèm mã quốc gia curl -X PUT {{BASE}}/api/v1/me/wizard/basic -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d '{"kind":"Người nước ngoài","name":"Emma Johnson","birthYear":1990,"passport":"PA1234567", "street":"21 King St","phone":"+61 412 345 678","prov":"Sydney, NSW","country":"AU"}' // Đã lưu giấy tờ cùng loại trước đó → có thể bỏ trống cccd/passport để giữ nguyên. - Cách cũ – 4 bước (vẫn hỗ trợ cho bản app cũ; các trường ngoài 7 trường trên đều là tuỳ chọn). Mỗi bước trả
next; sai dữ liệu → 422 kèmerrorstheo từng trường.curl -X PUT {{BASE}}/api/v1/me/wizard/personal -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d '{"kind":"Công dân Việt Nam","name":"Nguyễn Minh An","dob":"12/04/1995","gender":"Nam","nationality":"VN"}' → 200 {"ok":true,"step":"personal","data":{…},"next":"document"} curl -X PUT {{BASE}}/api/v1/me/wizard/document -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d '{"cccd":"000000000201"}' // khách quốc tế: {"passport":"M1234567","passportCountry":"KR","passportExp":"01/01/2031"} → 200 {"ok":true,"step":"document","data":{"docType":"cccd","cccdMasked":"001•••••345"},"next":"contact"} // số giấy tờ chỉ lưu dạng băm + bản che; mỗi giấy tờ chỉ cấp một ID (trùng → 409 duplicate_document) curl -X PUT {{BASE}}/api/v1/me/wizard/contact -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d '{"phone":"0912345678","email":"an@example.com","street":"12 Lê Lợi","ward":"Phường Hải Châu","prov":"Đà Nẵng", "kin":[{"name":"Trần Thị Hoa","rel":"Mẹ","phone":"0987654321","emg":true}]}' → 200 {… "next":"preferences"} curl -X PUT {{BASE}}/api/v1/me/wizard/preferences -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d '{"interests":["bien-dao","am-thuc"],"languages":["vi","en"]}' → 200 {… "next":"review"} // ví dụ lỗi → 422 {"error":{"code":"validation","message":"Thông tin chưa hợp lệ.","errors":{"dob":"Ngày sinh không hợp lệ (dd/mm/yyyy)"}}} // Vẫn nhận tên trường cũ v1.0 (fullName, docType/docNumber, province, gender "nam"/"nu", dob ISO) và tự chuyển đổi. - Hoàn tất – hiển thị nội dung
consent.texttừ/meta, chỉ gửi khi người dùng đã tích đồng ý.
App lưucurl -X POST {{BASE}}/api/v1/me/wizard/finish -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d '{"consent":true}' // một ô đồng ý xử lý dữ liệu (Luật 91/2025/QH15, NĐ 356/2025/NĐ-CP) – bắt buộc; dạng cũ {"terms":true,"travel":true} vẫn nhận → 201 {"ok":true,"tqid":"TQ482019379","message":"Bạn đã có ID định danh du lịch.", "card":{"tqid":"TQ482019379","name":"Nguyễn Minh An","nationality":"VN","nationalityName":"Việt Nam", "issuedAt":"2026-10-29T03:12:44.120Z","status":"active","qrPayload":"TQID:TQ482019379", "qrSvgUrl":"{{BASE}}/api/v1/me/card/qr.svg","demo":true, "note":"Mọi giao dịch trên các dịch vụ TravelQ đều dùng ID này."}, "profile":{…}}tqidvào hồ sơ người dùng và gửi kèm trong mọi giao dịch với các dịch vụ TravelQ. Tự vẽ QR từqrPayload(hoặc tảiqrSvgUrlkèm Bearer). - Hồ sơ, thẻ, giao dịch, chia sẻ dữ liệu, thiết bị:
GET /me → tài khoản + tqid + tiến độ GET /me/profile → hồ sơ đầy đủ (giấy tờ đã che) PATCH /me/profile {"email":"…","street":"…","ward":"…","prov":"Huế","kin":[…],"interests":[…]} // không sửa được ID, kind, ngày sinh, quốc tịch, số giấy tờ GET /me/card → dữ liệu thẻ ID số GET /me/transactions → giao dịch các dịch vụ đã ghi vào ID GET /me/consents → các dịch vụ + phạm vi đã cho phép PUT /me/consents/{site} {"scopes":["profile","contact"]} // [] = thu hồi GET /me/sessions → thiết bị đang đăng nhập DELETE /me/sessions/{sid} → đăng xuất thiết bị đó GET /me/services → ứng dụng đã kết nối (OIDC/chia sẻ) + hồ sơ dịch vụ liên kết DELETE /me/services/{site} → ngắt kết nối: xoá quyền, gỡ liên kết, thu hồi refresh token của ứng dụng POST /auth/logout → đăng xuất thiết bị hiện tại
Lưu & làm mới token
- Lưu
refreshTokentrong Keychain (iOS) / Keystore – EncryptedSharedPreferences (Android);accessTokencó thể giữ trong bộ nhớ. accessTokensống 15 phút. Khi API trả 401token_expired(hoặc trước khi hết hạn), gọi:curl -X POST {{BASE}}/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{"refreshToken":"tqr_…"}' → 200 {"tokenType":"Bearer","accessToken":"tqa_…","expiresIn":900,"refreshToken":"tqr_…(mới)","refreshExpiresIn":2592000}- Refresh token xoay vòng: mỗi token chỉ dùng được một lần, luôn ghi đè token mới. Dùng lại token cũ → 401
refresh_reusedvà phiên bị thu hồi (chống đánh cắp). Gộp các lần làm mới chạy song song thành một. - Gặp 401
invalid_refresh,refresh_reused,session_revoked→ xoá token, đưa về màn đăng nhập. - Tài khoản đổi mật khẩu bằng OTP (
/auth/password/reset) → mọi phiên cũ bị thu hồi.
Cách 2: mở /embed/ trong webview
Dùng khi app chưa dựng giao diện riêng. Du khách đã đăng nhập app TravelQ, nên đây là bước cập nhật thông tin để tạo Mã định danh du lịch toàn cầu, không phải đăng ký mới. Luồng gồm 2 trang:
/embed/bat-dau/– trang lời mời “Cập nhật thông tin để tạo Mã định danh du lịch toàn cầu”, nút Cập nhật thông tin và Để sau./embed/cap-nhat/– 4 bước: Thông tin (điền sẵn từ tài khoản app) → Giấy tờ (chụp CCCD 2 mặt / trang ảnh hộ chiếu) → Khuôn mặt → Xác nhận; cấp mã và hiện thẻ. Webview/iframe cần quyền camera (iframe:allow="camera"); không có camera thì du khách tải ảnh lên.
Cổng vào app: sau khi đăng nhập, đọc hasTravelId từ GET /auth/session hoặc GET /me (cũng có trong user của mọi kết quả đăng nhập). Nếu false → mở webview tới onboardingUrl (bản nhúng: thêm tiền tố /embed) trước khi cho dùng app; nếu true → vào thẳng hồ sơ. Mở /embed/ cũng được: người chưa có mã luôn được chuyển tới /embed/bat-dau/, người đã có mã vào thẳng hồ sơ.
GET /api/v1/auth/session (Authorization: Bearer tqa_…)
→ 200 {"authenticated":true,"tqid":null,"hasTravelId":false,"onboardingUrl":"/bat-dau/", …}
{{BASE}}/embed/bat-dau/?redirect_uri=travelq%3A%2F%2Fid%2Fissued&state=abc123
#access_token=tqa_…&refresh_token=tqr_… // bắt buộc khi mở từ app: phiên lấy từ app, không có màn đăng nhập
[&name=Nguyễn%20Thị%20Lan&email=…&phone=…] // tuỳ chọn (đặt trong ?…): điền sẵn nếu tài khoản chưa có, người dùng vẫn kiểm tra lại
[&parent_origin=https://travelq.vn] // chỉ khi nhúng iframe trên web, phải nằm trong CORS_ORIGINS
Màn đăng nhập riêng chỉ còn là phương án dự phòng cho bản web/demo khi không có token. Token trong phần #… không gửi lên máy chủ, trang sẽ xoá khỏi thanh địa chỉ ngay khi đọc. Nên lấy cặp token mới bằng /auth/refresh trước khi mở webview. redirect_uri phải bắt đầu bằng tiền tố được phép (mặc định travelq://).
Khi ID được cấp, trang gửi thông điệp {"type":"travelq-id:issued","tqid":"TQ482019379","state":"abc123"} qua mọi kênh sẵn có, sau ~2 giây chuyển tới travelq://id/issued?tqid=TQ482019379&state=abc123:
// React Native (react-native-webview)
<WebView source={{ uri: embedUrl }}
onMessage={(e) => { const m = JSON.parse(e.nativeEvent.data); if (m.type === 'travelq-id:issued') saveTqid(m.tqid); }}
onShouldStartLoadWithRequest={(r) => !r.url.startsWith('travelq://')} />
// iOS – WKWebView
config.userContentController.add(self, name: "travelqId")
func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
if let b = m.body as? [String: Any], b["type"] as? String == "travelq-id:issued" { save(b["tqid"] as! String) }
}
// Android – WebView
webView.addJavascriptInterface(object { @JavascriptInterface fun onIdIssued(json: String) { save(JSONObject(json).getString("tqid")) } }, "TravelQBridge")
// Web (iframe) – mở /embed/?parent_origin=https://travelq.vn
window.addEventListener('message', (e) => { if (e.origin === '{{BASE}}' && e.data.type === 'travelq-id:issued') save(e.data.tqid); });
Khi du khách bấm Để sau, trang gửi {"type":"travelq-id:deferred","state":"abc123"} qua cùng các kênh (Android: TravelQBridge.onDeferred) rồi chuyển tới travelq://id/issued?status=later&state=abc123. App có thể cho dùng tạm nhưng nên mở lại trang lời mời ở lần vào sau, vì người chưa có mã vẫn bị đưa về /bat-dau/.
Luôn kiểm tra state trùng với giá trị app đã gửi, và xác nhận lại bằng GET /me trước khi tin tqid.
TRƯỜNG DỮ LIỆU TÀI KHOẢN LÕI
Lõi TravelQ ID chỉ lưu các trường dưới đây.
| Trường | Ý nghĩa | Định dạng | Phạm vi (scope) để dịch vụ đọc |
|---|---|---|---|
| 7 trường thu thập (①–⑦, bắt buộc khi cấp mã) – các trường còn lại là tuỳ chọn, giữ để tương thích. | |||
kind | Đối tượng | "Công dân Việt Nam" (Khách Việt Nam) | "Người nước ngoài" (Khách quốc tế) – giá trị API giữ nguyên | profile |
① name | Họ và tên | chuỗi, 2–80 ký tự | profile |
② birthYear | Năm sinh | yyyy (số), trong 120 năm gần nhất | profile |
dob | Ngày sinh (tuỳ chọn) | dd/mm/yyyy (OIDC birthdate = yyyy-mm-dd) | profile |
gender | Giới tính (tuỳ chọn) | "Nam" | "Nữ" | "Khác" | profile |
nationality | Quốc tịch (tự lấy theo quốc gia nếu không gửi) | mã ISO-3166 alpha-2 (VN, KR…) | profile |
③ cccd | Số CCCD (12 số) – công dân Việt Nam | chỉ nhận vào; lưu băm + cccdMasked | document |
③ passport | Số hộ chiếu (chữ + số) – khách quốc tế | chỉ nhận vào; lưu băm + passportMasked | document |
passportCountry | Nước cấp hộ chiếu (tuỳ chọn, mặc định = quốc gia) | ISO alpha-2 | document |
passportExp | Hạn hộ chiếu (tuỳ chọn) | dd/mm/yyyy | document |
⑤ phone | Số điện thoại | 0xxxxxxxxx (VN) hoặc +mã nước (OIDC phone_number dạng E.164) | phone |
email | Email (tuỳ chọn) | tuỳ chọn | email |
④ street | Địa chỉ (số nhà, đường…) | chuỗi, tối đa 120 ký tự | address |
ward | Phường/Xã (tuỳ chọn) | tuỳ chọn | address |
⑥ prov | Tỉnh/Thành phố | công dân VN: 34 tỉnh/thành sau sắp xếp 2025; khách quốc tế: tự nhập | address |
⑦ country | Quốc gia | ISO-3166 alpha-2; công dân VN luôn là VN (kèm countryName khi đọc) | address |
addr | Địa chỉ đầy đủ | tự ghép từ street, ward, prov (chỉ đọc) | address |
kin[] | Người liên hệ khẩn cấp (tuỳ chọn) | {name, rel, phone, emg, view} – tối đa 3 | emergency |
interests, languages | Sở thích, ngôn ngữ (tuỳ chọn, riêng của TravelQ) | mảng mã trong /meta | travel |
Không lấy về lõi: dân tộc, nghề nghiệp, nơi sinh, ngày cấp/nơi cấp CCCD. Số giấy tờ đầy đủ không bao giờ được trả ra; dịch vụ cần đối chiếu dùng document/match.
ĐĂNG NHẬP BẰNG TRAVELQ ID (OIDC)
Mọi ứng dụng/website (của TravelQ hoặc đơn vị được quản trị đăng ký) có thể cho người dùng “Đăng nhập bằng TravelQ ID” theo chuẩn OAuth 2.0 + OpenID Connect: luồng authorization code + PKCE (S256). id_token ký RS256, sub = mã tài khoản lõi (không đổi, không phải số giấy tờ).
Discovery : {{BASE}}/.well-known/openid-configuration
JWKS : {{BASE}}/.well-known/jwks.json
Authorize : {{BASE}}/oauth/authorize Token: {{BASE}}/oauth/token
UserInfo : {{BASE}}/oauth/userinfo Revoke: {{BASE}}/oauth/revoke
Scopes : openid profile email phone address document emergency travel transactions offline_access
- Đăng ký ứng dụng ở /admin → “Ứng dụng & API”: chọn loại public (app di động/SPA, không secret) hoặc confidential (máy chủ, có secret), khai báo redirect URI chính xác (https://…, scheme riêng như
travelq://…, hoặc http://localhost khi phát triển) và các scope được phép. Nhậnclient_idtqc_…, API keytqk_…(với confidential đây cũng làclient_secret) và webhook secret – chỉ hiển thị một lần. - Chuyển người dùng tới trang đăng nhập (trình duyệt hệ thống / ASWebAuthenticationSession / Custom Tabs – không dùng webview cho bước này):
Người dùng đăng nhập (mật khẩu / SĐT / Gmail), lập ID nếu chưa có, rồi thấy màn hình đồng ýverifier = base64url(32 byte ngẫu nhiên) challenge = base64url(SHA256(verifier)) {{BASE}}/oauth/authorize?response_type=code&client_id=tqc_…&redirect_uri=travelq%3A%2F%2Foauth%2Fcallback &scope=openid%20profile%20phone%20offline_access&state=…&nonce=…&code_challenge=…&code_challenge_method=S256/authorize/ghi rõ ứng dụng nào xin dữ liệu gì và có thể bỏ chọn từng mục. Kết quả trả vềredirect_uri?code=…&state=…hoặc?error=access_denied&state=…. - Đổi code lấy token (code sống 5 phút, dùng một lần):
curl -X POST {{BASE}}/oauth/token -d grant_type=authorization_code -d code=… \ -d redirect_uri=travelq://oauth/callback -d client_id=tqc_… -d code_verifier=… [-u tqc_…:tqk_… // ứng dụng confidential: HTTP Basic hoặc client_secret trong form] → 200 {"access_token":"tqo_…","token_type":"Bearer","expires_in":900,"scope":"openid profile phone offline_access", "id_token":"eyJ…","refresh_token":"tqrt_…"} // refresh_token chỉ khi có offline_access - Kiểm tra id_token: chữ ký RS256 theo JWKS (
kid),iss={{BASE}},aud= client_id,exp,nonce. Claims:sub,tqid, và theo scope:name,birthdate,gender,email,phone_number. - UserInfo:
GET /oauth/userinfovớiAuthorization: Bearer tqo_…→ dữ liệu theo đúng phạm vi người dùng đã cho phép, dùng tên trường lõi (name,dob,prov,kin…) cùng các claim chuẩn OIDC. - Làm mới:
grant_type=refresh_token&refresh_token=tqrt_…&client_id=…. Refresh token xoay vòng; dùng lại token cũ ⇒ cả chuỗi bị thu hồi. Người dùng ngắt kết nối ứng dụng ⇒ refresh token và access token của ứng dụng đó hết hiệu lực. Đăng xuất phía ứng dụng:POST /oauth/revoke token=tqrt_….
Lỗi: invalid_client, invalid_redirect_uri (hiển thị tại TravelQ, không chuyển về redirect lạ), invalid_request (thiếu PKCE), invalid_scope, access_denied, invalid_grant, unsupported_grant_type. prompt=none chưa hỗ trợ (trả interaction_required).
SPA thuần trình duyệt: endpoint /oauth/token chỉ cho origin nằm trong CORS_ORIGINS; khuyên dùng ứng dụng di động hoặc máy chủ (BFF) đổi token.
API CHO DỊCH VỤ
Mỗi trang/dịch vụ (khách sạn, nhà hàng, vé, hộ chiếu du lịch…) được quản trị cấp một API key tqk_…. Key chỉ hiển thị một lần, máy chủ chỉ lưu bản băm; có thể thu hồi bất cứ lúc nào. Chỉ gọi từ máy chủ – không nhúng key vào app hay mã trình duyệt.
GETXác minh ID (không cần du khách cho phép)
curl {{BASE}}/api/v1/partner/ids/TQ482019379/verify -H "X-API-Key: $TQ_KEY"
→ 200 {"tqid":"TQ482019379","valid":true,"status":"active","maskedName":"N. M. An","nationality":"VN","issuedYear":2026,"demo":true}
→ 400 {"error":{"code":"invalid_tqid",…}} // sai định dạng / sai ký tự kiểm tra
→ 404 {"tqid":"…","valid":false,"error":{"code":"not_found",…}}
GETTra cứu hồ sơ theo quyền du khách cho phép
Du khách cho phép từng dịch vụ trong hồ sơ hoặc qua màn đồng ý OIDC (phạm vi: profile, email, phone, address, document, emergency, travel, transactions; contact = email+phone+address, preferences = travel). Chưa cho phép → 403 consent_required. Dữ liệu trả về chỉ gồm các phạm vi ứng dụng được phép xin và người dùng đã đồng ý.
curl {{BASE}}/api/v1/partner/ids/TQ482019379 -H "X-API-Key: $TQ_KEY"
→ 200 {"sub":"u_…","tqid":"TQ482019379","status":"active","scopes":["profile","email","phone","address"],
"kind":"Công dân Việt Nam","name":"Nguyễn Minh An","dob":"12/04/1995","birthdate":"1995-04-12","gender":"Nam","nationality":"VN",
"email":"an@example.com","phone":"0912345678","phoneE164":"+84912345678",
"street":"12 Lê Lợi","ward":"Phường Hải Châu","prov":"Đà Nẵng","addr":"12 Lê Lợi, Phường Hải Châu, Đà Nẵng"}
// thêm "document" → docType + cccdMasked/passportMasked; "emergency" → kin[]; "travel" → interests, languages
POSTGhi nhận giao dịch vào ID
type: booking, payment, refund, order, ticket, checkin, checkout, passport, voucher, other. ref là mã đơn phía bạn – gửi lại cùng ref không tạo bản ghi trùng. ID đang bị khoá → 423 id_locked.
curl -X POST {{BASE}}/api/v1/partner/ids/TQ482019379/transactions -H "X-API-Key: $TQ_KEY" -H 'Content-Type: application/json' \
-d '{"type":"booking","ref":"HD-10293","amount":1250000,"currency":"VND","description":"Đặt phòng 2 đêm","occurredAt":"2026-10-29T09:30:00+07:00"}'
→ 201 {"ok":true,"transaction":{"id":"tx_…","tqid":"TQ482019379","site":"hotel","type":"booking","amount":1250000,"currency":"VND","ref":"HD-10293",…}}
→ 200 {"ok":true,"idempotent":true,"transactionId":"tx_…"} // gửi lại cùng ref
GETDanh sách giao dịch
curl {{BASE}}/api/v1/partner/ids/TQ482019379/transactions -H "X-API-Key: $TQ_KEY"
→ 200 {"tqid":"…","scope":"own_site","items":[…]} // "all_sites" nếu du khách cho phép phạm vi transactions
Liên kết hồ sơ dịch vụ (service profile)
Mỗi dịch vụ giữ hồ sơ của riêng mình (mã hội viên…) và gắn vào tài khoản lõi bằng sub. Cần người dùng đã cho phép ứng dụng (trừ ứng dụng nội bộ first-party).
GET /partner/users/{sub} → hồ sơ theo phạm vi được phép + "link"
PUT /partner/users/{sub}/link {"externalId":"HV-00123","label":"Hội viên Vàng"}
DELETE /partner/users/{sub}/link
GET /partner/links/{externalId} → {"sub","tqid","status","link"} // tra ngược từ mã phía bạn
POST /partner/users/{sub}/document/match {"cccd":"000000000201"} → {"docType":"cccd","match":true} // cần scope document; không bao giờ trả số đầy đủ
Một externalId chỉ gắn với một người trong mỗi dịch vụ (trùng → 409 external_id_taken). Ứng dụng có thể liệt kê các liên kết qua GET /me/consents và ngắt kết nối bằng PUT /me/consents/{site} với [].
Kiểm tra key: GET /partner/me. Nếu gọi từ trình duyệt của chính trang dịch vụ, khai báo origin cho key khi tạo – origin khác sẽ bị từ chối.
WEBHOOK
Khai báo webhookUrl (https) và các sự kiện khi đăng ký ứng dụng. TravelQ ID gửi POST JSON tới ứng dụng là first-party hoặc được người dùng đó cho phép. Payload không chứa dữ liệu cá nhân – nhận sự kiện xong, gọi API để lấy dữ liệu theo phạm vi được phép.
POST https://app.example/hooks/travelq
x-tqid-event: profile.updated
x-tqid-delivery: evt_…
x-tqid-signature: t=1793251200,v1=5f2c…(hex)
{"id":"evt_…","type":"profile.updated","createdAt":"2026-10-29T03:00:00.000Z",
"data":{"sub":"u_…","tqid":"TQ482019379","status":"active","changed":["street","addr"]}}
| Sự kiện | Khi nào |
|---|---|
id.issued | Người dùng vừa được cấp TravelQ ID (gửi cho ứng dụng first-party) |
profile.updated | Người dùng sửa hồ sơ; changed liệt kê tên trường |
id.locked / id.unlocked | Quản trị khoá / mở khoá ID – nên chặn giao dịch tương ứng |
ping | Nút “Gửi thử webhook” trong /admin |
Xác minh chữ ký trước khi tin dữ liệu: v1 = HMAC-SHA256(webhookSecret, t + "." + rawBody) dạng hex, so sánh thời gian hằng, và từ chối nếu t lệch quá 5 phút.
// Node
import crypto from 'node:crypto';
function verify(raw, header, secret) {
const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || ''); if (!m) return false;
if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const exp = crypto.createHmac('sha256', secret).update(m[1] + '.' + raw).digest('hex');
return crypto.timingSafeEqual(Buffer.from(exp), Buffer.from(m[2]));
}
// hoặc: TravelQId.verifyWebhook(raw, req.headers['x-tqid-signature'], process.env.TQ_WEBHOOK_SECRET)
Trả 2xx trong 4 giây. Gửi tối đa 2 lần; nhật ký gửi xem ở /admin. Có thể nhận trùng – loại trùng theo id.
KẾT NỐI APP TRAVELQ (MÁY CHỦ)
Kết nối dữ liệu nội bộ dành cho máy chủ (backend) của App TravelQ – và các hệ thống dữ liệu khác của chính TravelQ – để đồng bộ du khách, Travel ID, số CCCD/hộ chiếu, giao dịch theo kiểu máy chủ ↔ máy chủ. Giao diện app vẫn dùng token người dùng và trang /embed/ (xem Cách 2). Địa chỉ gốc: {{BASE}}/api/v1/data. Khác với API dịch vụ ở trên, khoá dữ liệu đọc được mọi hồ sơ trong phạm vi được cấp – chỉ cấp cho hệ thống nội bộ, không dùng trong trình duyệt hay app (yêu cầu có header Origin bị từ chối 403).
Tạo khoá: /admin → tab “Kết nối App TravelQ”. Mỗi khoá có client_id (tqd_…), client_secret (tqds_…, hiện một lần), phạm vi, danh sách IP được phép (tuỳ chọn), giới hạn lượt gọi/phút, webhook (tuỳ chọn). Thu hồi hoặc đổi secret có hiệu lực ngay, kể cả token đã cấp.
Luồng đầy đủ cho App TravelQ
- Giao diện app (đã đăng nhập) gọi
GET /api/v1/auth/sessionbằng token của người dùng. NếuhasTravelId=false, app mở webview{{BASE}}/embed/bat-dau/?redirect_uri=travelq%3A%2F%2Fid%2Fissued&state=…#access_token=tqa_…&refresh_token=tqr_…. Người dùng điền thông tin và nhận ID; webview gửitravelq-id:issuedcho app. - Backend app lưu Travel ID theo người dùng của app. Có thể nhận webhook
id.issued(data.sub,data.tqid= Travel ID) rồi gọiGET /data/travellers/{travelId}, hoặc đồng bộ định kỳGET /data/travellers?cursor=…. Không tintqiddo giao diện gửi lên mà chưa đối chiếu bằng Data API. - Đẩy đơn đặt chỗ thành giao dịch:
POST /data/travellers/{travelId}/transactionsvớiref= mã đơn của app (gửi lại không bị trùng). - Ghép người dùng app ↔
sub:sub(u_…) là mã bất biến của tài khoản TravelQ ID, có tronguser.subcủa kết quả đăng nhập/đăng ký và trongaccount.subcủa/auth/session. Backend lưu cặp (mã người dùng app,sub) ngay khi người dùng đăng nhập; mọi sự kiện/hồ sơ sau đó ghép theosub. Người dùng cũ chưa có cặp này: traPOST /data/lookup {"phone":"…"}rồi xác nhận lại với người dùng.
// Backend app – khi nhận webhook id.issued (đã kiểm tra chữ ký)
const { sub, tqid: travelId } = evt.data;
const t = await fetch(`${BASE}/api/v1/data/travellers/${travelId}`, { headers: { authorization: basic } }).then((r) => r.json());
await db.users.update({ travelqSub: sub }, { travelId, documentType: t.document.type, documentNumber: t.document.number /* chỉ khi có read:documents_full */ });
// Khi có đơn đặt chỗ
await fetch(`${BASE}/api/v1/data/travellers/${travelId}/transactions`, { method: 'POST', headers: { authorization: basic, 'content-type': 'application/json' },
body: JSON.stringify({ type: 'booking', ref: order.code, amount: order.total, currency: 'VND', description: order.title }) });
| Phạm vi | Cho phép |
|---|---|
read:travellers | Hồ sơ đầy đủ: 7 trường (họ tên, năm sinh, CCCD/hộ chiếu dạng che, địa chỉ, SĐT, tỉnh/thành, quốc gia) + loại khách, Travel ID, ngày cấp, trạng thái; xuất dữ liệu |
read:ids | Chỉ Travel ID, trạng thái, ngày cấp, tên che |
read:transactions / write:transactions | Đọc / ghi giao dịch gắn với Travel ID |
read:events | Nhật ký sự kiện và webhook |
read:documents_full | Số giấy tờ đầy đủ – chỉ khi máy chủ bật mã hoá (xem bên dưới) |
Xác thực
# Cách 1 – Basic (đơn giản, mỗi yêu cầu gửi kèm secret)
curl {{BASE}}/api/v1/data/me -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
# Cách 2 – token client_credentials (hết hạn sau 15 phút, có thể thu hẹp phạm vi)
curl -X POST {{BASE}}/api/v1/data/token -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET" \
-d grant_type=client_credentials -d scope="read:travellers read:events"
→ 200 {"access_token":"tqdt_…","token_type":"Bearer","expires_in":900,"scope":"read:travellers read:events"}
curl {{BASE}}/api/v1/data/travellers -H "Authorization: Bearer $TQD_TOKEN"
Đồng bộ & xuất dữ liệu
Danh sách sắp theo thời điểm cập nhật tăng dần. Lần đầu gọi không có tham số (hoặc updated_since), lưu nextCursor; các lần sau gọi với cursor để chỉ nhận hồ sơ mới hoặc vừa thay đổi. Cập nhật vào kho theo khoá travelId (upsert).
curl "{{BASE}}/api/v1/data/travellers?limit=200&updated_since=2026-10-01T00:00:00Z" -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
→ 200 {"items":[{
"travelId":"TQ482019379","sub":"u_…","kind":"vn","kindLabel":"Công dân Việt Nam","status":"active",
"issuedAt":"2026-10-29T03:00:00.000Z","updatedAt":"2026-10-30T02:10:00.000Z","lockedAt":null,
"name":"Nguyễn Văn An","birthYear":1990,
"document":{"type":"cccd","masked":"079•••••345"},
"street":"12 Lê Lợi","phone":"0901234567","phoneE164":"+84901234567","prov":"Gia Lai","country":"VN","countryName":"Việt Nam",
"email":"","dob":"","gender":"","nationality":"VN","ward":"","addr":"12 Lê Lợi, Gia Lai",
"consent":{"version":"…","at":"…","share":false},"demo":false }],
"nextCursor":"…","hasMore":true,"view":"full"}
curl "{{BASE}}/api/v1/data/travellers?cursor=$CURSOR" -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
Lọc thêm: status=active|locked, kind=vn|intl, demo=exclude|only; limit tối đa 500 (mặc định 100). Khoá chỉ có read:ids nhận dạng rút gọn ("view":"ids").
# Một hồ sơ theo Travel ID (nhận cả "TQ 482 019 379")
curl {{BASE}}/api/v1/data/travellers/TQ482019379 -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
# Trạng thái ID (không có dữ liệu cá nhân) – một hoặc tối đa 100 ID
curl {{BASE}}/api/v1/data/ids/TQ482019379 -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
→ 200 {"valid":true,"travelId":"TQ482019379","status":"active","issuedAt":"…","maskedName":"N. V. An",…}
curl -X POST {{BASE}}/api/v1/data/ids/verify -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET" -H 'Content-Type: application/json' \
-d '{"travelIds":["TQ482019379","TQ000"]}'
# Tra cứu theo SĐT / CCCD / hộ chiếu – gửi trong thân POST để dữ liệu cá nhân không nằm trên URL
curl -X POST {{BASE}}/api/v1/data/lookup -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET" -H 'Content-Type: application/json' \
-d '{"phone":"+84901234567"}' # hoặc {"cccd":"079…"} / {"passport":"…"} / {"travelId":"…"}
→ 200 {"found":true,"by":"phone","items":[{…hồ sơ…}]}
# Tài khoản theo sub (kể cả tài khoản chưa được cấp ID)
curl {{BASE}}/api/v1/data/accounts/u_… -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
# Xuất toàn bộ: json | ndjson | csv (UTF-8 có BOM, mở được bằng Excel), tối đa 5000 bản ghi mỗi lần
curl "{{BASE}}/api/v1/data/export?format=csv&limit=5000" -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET" -D headers.txt -o travelq-id.csv
# header x-has-more: true → gọi tiếp với ?cursor=<x-next-cursor>
Giao dịch & sự kiện
# Ghi một giao dịch (idempotent theo ref – gửi lại cùng ref trả 200 "idempotent":true)
curl -X POST {{BASE}}/api/v1/data/travellers/TQ482019379/transactions -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET" \
-H 'Content-Type: application/json' \
-d '{"type":"booking","ref":"BK-10293","amount":1250000,"currency":"VND","description":"Đặt phòng 2 đêm","occurredAt":"2026-10-29T09:30:00+07:00"}'
→ 201 {"ok":true,"transaction":{"id":"tx_…","tqid":"TQ482019379","site":"kho-du-lieu",…}}
# Ghi lô (tối đa 100), kết quả theo từng dòng
curl -X POST {{BASE}}/api/v1/data/transactions -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET" -H 'Content-Type: application/json' \
-d '{"items":[{"travelId":"TQ482019379","type":"order","ref":"OD-1","amount":120000}]}'
→ 200 {"ok":true,"created":1,"results":[{"index":0,"status":201,"ok":true,"transactionId":"tx_…"}]}
# Đọc giao dịch: toàn hệ thống (cursor/since/source) hoặc theo ID
curl "{{BASE}}/api/v1/data/transactions?since=2026-10-01T00:00:00Z&source=hotel" -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
curl {{BASE}}/api/v1/data/travellers/TQ482019379/transactions -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
# Nhật ký sự kiện (đọc bù khi webhook lỗi)
curl "{{BASE}}/api/v1/data/events?types=id.issued,profile.updated&cursor=$EV_CURSOR" -u "$TQD_CLIENT_ID:$TQD_CLIENT_SECRET"
Webhook dữ liệu: sự kiện traveller.created (tạo tài khoản), id.issued, profile.updated, id.locked, id.unlocked. Cùng định dạng và cách ký như Webhook ở trên (x-tqid-signature: t=…,v1=HMAC-SHA256(webhookSecret, t + "." + body)); payload chỉ có sub, travelId, status, changed – không có dữ liệu cá nhân. Nhận sự kiện rồi gọi GET /data/travellers/{travelId}.
Số giấy tờ đầy đủ (tuỳ chọn)
Mặc định TravelQ ID không lưu số CCCD/hộ chiếu đầy đủ – chỉ bản băm (chặn trùng) và bản che, nên API trả document.masked. Khi quản trị đặt biến môi trường DATA_ENCRYPTION_KEY (32 byte, hex 64 ký tự hoặc base64), hồ sơ tạo/cập nhật giấy tờ từ thời điểm đó được lưu thêm bản mã hoá AES-256-GCM; khoá có read:documents_full nhận thêm:
"document":{"type":"cccd","masked":"079•••••345","number":"000000000207","numberStatus":"ok"}
// numberStatus: ok | encryption_disabled | not_stored (hồ sơ trước khi bật) | decrypt_failed
Mỗi lần trả số đầy đủ đều ghi nhật ký (data.document_full_read). Số đầy đủ không bao giờ trả qua API du khách, dịch vụ hay trang quản trị. Đổi khoá: đặt khoá mới vào DATA_ENCRYPTION_KEY, khoá cũ vào DATA_ENCRYPTION_KEY_PREVIOUS.
Xác minh giấy tờ & khuôn mặt (d15)
Khi khoá có read:documents_full, mỗi hồ sơ có thêm verification – chỉ siêu dữ liệu, API dữ liệu không trả ảnh giấy tờ. Điểm liveness/match hiện là mô phỏng (method: "simulated"), ngưỡng đạt liveness ≥ 0.90 và match ≥ 0.80, tối đa 2 lượt; hết lượt → manual_review. Ảnh khuôn mặt bị xoá ngay sau khi so khớp, không lưu mẫu sinh trắc học. Ảnh giấy tờ chỉ được lưu khi máy chủ có DATA_ENCRYPTION_KEY (mã hoá AES-256-GCM).
"verification":{"face":{"status":"face_verified","verified":true,"at":"2026-10-30T08:12:00.000Z","liveness":0.951,"match":0.874,"attempts":1,"method":"simulated"},
"documentImages":[{"side":"front","capturedAt":"…","stored":true},{"side":"back","capturedAt":"…","stored":true}],
"biometricConsent":{"version":"2026-10-bio-v1","at":"…"}}
// face.status: not_done | failed | face_verified | manual_review
Quét giấy tờ để tự điền (d16)
Ở bước 1 của luồng web/nhúng, khách có thể bấm Quét CCCD để tự điền (đọc mã QR trên CCCD gắn chip) hoặc Scan passport to auto-fill (đọc 2 dòng MRZ trên trang ảnh hộ chiếu). Việc đọc diễn ra hoàn toàn trên máy khách; API không nhận chuỗi QR/MRZ gốc mà chỉ nhận các trường biểu mẫu như khi nhập tay (PUT /me/wizard), nên tích hợp hiện có không phải thay đổi. Trường tự điền được gắn nhãn và khách vẫn sửa được; số điện thoại luôn nhập tay. Ảnh chụp lúc quét được dùng lại ở bước Giấy tờ. Hộ chiếu chỉ được điền khi các chữ số kiểm tra MRZ hợp lệ.
Chụp giấy tờ một lần, tự điền im lặng (d18)
Từ d18, bước 1 có thẻ Ảnh mặt trước CCCD / Passport photo page: khách chụp (nút Chụp lớn) hoặc tải ảnh lên một lần. Ảnh luôn được lưu mã hoá khi bấm Tiếp tục (PUT /me/wizard/document-image sau khi lưu bước 1). Trình duyệt đọc thầm chính ảnh đó (QR trên CCCD, MRZ trên hộ chiếu); đọc được thì điền sẵn các trường (vẫn sửa được, không ghi đè ô khách đã gõ), không đọc được thì không hiện thông báo nào – khách nhập tay. Không có bước quét trực tiếp riêng, không có lựa chọn Chụp lại/Tải ảnh lên/Tự nhập sau khi đọc thất bại.
- AI dự phòng (mặc định tắt): từ d22 chỉ gọi ở bước Xác nhận, SAU khi khách đồng ý và chỉ khi đọc trên máy thất bại – xem Hướng dẫn gắn AI quét giấy tờ. Mặc định trả
503 docai_unavailablevà không gọi dịch vụ ngoài. - Cờ:
TRAVELQ_DOCSCAN=0tắt đọc thầm;TRAVELQ_DOCSCAN_LIVE=1bật lại máy quét trực tiếp d16/d17 (thử nghiệm).GET /me/wizardtrảdocScan:{"enabled":true,"live":false}. - (d19: bước Giấy tờ riêng đã bỏ – xem mục dưới.)
CCCD 2 mặt ngay ở bước 1 (d19)
Từ d19 luồng còn 3 bước: Thông tin (kèm ảnh giấy tờ) → Khuôn mặt → Xác nhận. Khách Việt Nam chụp lần lượt 1/2 Mặt trước rồi 2/2 Mặt sau CCCD ngay ở bước 1 (camera tự mở cho mặt sau sau khi chụp mặt trước; có Tải ảnh lên); mỗi mặt đã có ảnh hiện ảnh nhỏ và nút Chụp lại. Khách quốc tế giữ một ảnh trang hộ chiếu. Khi bấm Tiếp tục, trình duyệt lưu bước 1 rồi gửi từng ảnh tới PUT /api/v1/me/wizard/document-image {"side":"front"|"back"|"passport","image":"data:image/jpeg;base64,…","consent":true,"source":"camera"|"upload"} (lưu mã hoá); thiếu ảnh thì ở lại bước 1 với lời nhắc rõ mặt còn thiếu. Chỉ mặt trước (QR) / trang hộ chiếu (MRZ) được đọc thầm như d18; mặt sau chỉ lưu, không đọc và không gọi AI.
Chữ ngắn gọn & đồng ý bio-v3 (d20)
Bước 1 dùng lời dẫn một câu, thẻ Mặt trước / Mặt sau (1/2, 2/2) đặt cạnh nhau, chụp xong chỉ hiện Đã chụp ✓ + Chụp lại. Đồng ý lưu ảnh dùng phiên bản 2026-10-bio-v3 – cùng nội dung pháp lý với 2026-10-bio-v2: hiện tiêu đề + bản tóm tắt ngắn, toàn văn nằm trong Xem chi tiết. GET /api/v1/me/wizard trả bioConsent: {"text", "summary", "version": "2026-10-bio-v3"}; gửi ảnh vẫn cần "consent": true và phiên bản được ghi nhận. Không đổi API hay chức năng khác.
Bảo mật dữ liệu, đồng ý trước khi thu thập & check-in bằng khuôn mặt (d21)
Bảo mật dữ liệu (hiện ở trang vào – thẻ gọn có “Xem thêm” – và trong Hồ sơ, 8 ngôn ngữ):
Toàn bộ thông tin cá nhân và hồ sơ của người dùng được số hóa, mã hóa và gắn với một ID định danh duy nhất gồm chữ và số. Người dùng sử dụng ID này xuyên suốt quá trình đăng ký, giao dịch và trải nghiệm các dịch vụ trên nền tảng.
Chỉ các nhà cung cấp dịch vụ thuộc hệ sinh thái, được cấp quyền phù hợp, mới có thể truy cập những thông tin cần thiết để phục vụ khách hàng, trong phạm vi được người dùng cho phép hoặc theo quy định pháp luật.
Dữ liệu được bảo vệ bằng các biện pháp kỹ thuật và quy trình quản lý an toàn thông tin theo tiêu chuẩn ISO/IEC 27001:2022.
Không thu thập trước khi đồng ý. Ảnh giấy tờ (CCCD 2 mặt / trang hộ chiếu) và ảnh khuôn mặt chụp ở bước 1–2 chỉ nằm trên thiết bị (bộ nhớ + IndexedDB của trình duyệt, tự xoá khi đăng xuất hoặc sau 24 giờ). Việc đọc thầm QR/MRZ mặt trước diễn ra trên máy. Ảnh chỉ được gửi (mã hoá) lên máy chủ khi khách tick đồng ý ở bước Xác nhận và bấm Hoàn tất; máy chủ từ chối ảnh gửi không kèm đồng ý (422 consent_required). Không còn kho “tạm 24 giờ” trên máy chủ. Móc AI đọc giấy tờ (doc-read) vẫn tắt.
Đồng ý phiên bản 2026-10-v4 – hai ô, không tick sẵn, “Xem chi tiết” mở toàn văn:
- (a) bắt buộc – xử lý thông tin đã khai báo và lưu ảnh giấy tờ (mã hoá) để khai báo lưu trú, lưu đến khi khách yêu cầu xoá. Trình duyệt gửi
PUT /api/v1/me/wizard/document-imagevới"consent":true,"consentVersion":"2026-10-v4". - (b) không bắt buộc – lưu ảnh khuôn mặt (mã hoá) để check-in bằng khuôn mặt tại khách sạn (khách nhìn vào camera, không cần xuất trình CCCD). Chỉ khi tick (b) trình duyệt mới gửi
POST /api/v1/me/wizard/facevới"faceConsent":true; liveness + so khớp chạy lúc đó. Không tick → ảnh khuôn mặt bị bỏ trên thiết bị, không lưu, check-in bằng khuôn mặt tắt.
Hồ sơ hiện ảnh đã lưu và trạng thái check-in bằng khuôn mặt; nút Yêu cầu xoá ảnh giấy tờ và khuôn mặt (POST /api/v1/me/document-images/delete {"confirm":true}) xoá cả hai. Nhân viên đăng nhập Hệ thống quét có thể xem ảnh giấy tờ và ảnh khuôn mặt; mỗi lượt xem được ghi nhật ký.
API check-in bằng khuôn mặt (cho ứng dụng check-in của khách sạn, gọi từ máy chủ):
POST /api/v1/checkin/face-verify
X-API-Key: tqk_… (khoá nội bộ – first-party – được quản trị bật “check-in bằng khuôn mặt”)
{"tqId":"TQ482019379","selfie":"data:image/jpeg;base64,…"}
→ 200 {"tqId":"TQ482019379","match":true,"score":0.874,"threshold":0.8,"method":"simulated",
"checkedAt":"…","maskedName":"N. M. An"}
- Không bao giờ trả ảnh – chỉ trả kết quả khớp/không khớp và điểm.
- Lỗi:
401thiếu/sai khoá ·403 scope_not_allowedkhoá không có quyền ·409 face_not_enrolledkhách không tick (b) hoặc đã xoá ảnh → check-in bằng giấy tờ ·404 not_found·423 id_locked·422 validation. - Mọi lượt gọi (kể cả bị từ chối) ghi nhật ký
checkin.face_verify. - Bộ so khớp hiện là mô phỏng (
method: "simulated"); cần thay bằng bộ so khớp thật trước khi dùng thực tế.
Giới hạn & nhật ký
- Mặc định 300 lượt/phút mỗi khoá (đặt riêng 10–6000); quá giới hạn trả 429 kèm
Retry-After. Sai secret nhiều lần từ một IP bị chặn tạm thời. - Mọi lượt gọi được ghi theo khoá (xem ở /admin → khoá → “Lượt gọi”); tra cứu, xuất dữ liệu và ghi giao dịch ghi thêm vào nhật ký hệ thống.
- Lỗi:
invalid_data_key(401),data_key_revoked,insufficient_scope,ip_not_allowed,browser_not_allowed(403),invalid_cursor(400).
Lấy lại tài khoản khi giấy tờ đã có TravelQ ID (d22)
Trang web không có mật khẩu: tài khoản web dùng cookie phiên (7 ngày). Mất phiên (đổi máy, xoá dữ liệu trình duyệt) mà nhập lại giấy tờ đã có ID → màn hình nhẹ nhàng (không báo đỏ) với nút “Đây là giấy tờ của tôi – Lấy lại tài khoản” (8 ngôn ngữ). Trang vào và bước 1 cũng có liên kết “Quên / không vào được tài khoản?” → nhập số giấy tờ + năm sinh.
- Đường A – OTP: gửi mã tới SĐT/email đã lưu (chỉ hiện dạng che
09•••678). Không lộ trường nào trước khi OTP đúng. Đúng → đăng nhập vào hồ sơ cũ, bản nháp đang dở bị bỏ; khách có thể chọn cập nhật hồ sơ cũ bằng thông tin vừa nhập. 5 OTP/giờ/giấy tờ; sai năm sinh + sai OTP cộng chung, 5 lần → khoá 60 phút. - Đường B – đổi/mất số: ảnh giấy tờ + ảnh khuôn mặt (chỉ gửi SAU khi tick đồng ý) + số mới → hàng chờ Yêu cầu lấy lại tài khoản ở /quet/. Nhân viên vai trò
scannerxem ảnh, duyệt (đổi SĐT, OTP tới số mới) hoặc từ chối (bắt buộc lý do). Khách tra trạng thái bằng ticket. - Nhật ký:
recover.start,recover.otp_sent,recover.otp_failed,recover.locked,recover.success,recover.image,recover.request_created,recover.request_claimed; nhân viên:scan.recovery_list,scan.recovery_view,scan.recovery_approve,scan.recovery_reject(ảnh của yêu cầu bị xoá ngay sau khi duyệt/từ chối). OTP mô phỏng:demoCodechỉ trả khiOTP_PROVIDERlà mô phỏng.
POST /api/v1/recover/start {"docType":"cccd","docNumber":"…","birthYear":"1990"} → {"rid","channels":[{"channel":"sms","masked":"09•••678"}]}
POST /api/v1/recover/otp {"rid","channel":"sms"} → {"sent":true,"masked":"09•••678"}
POST /api/v1/recover/verify {"rid","otp":"123456"} → Set-Cookie + {"tqid","draftDiscarded":true}
POST /api/v1/recover/image {"rid","side":"front|back|face","image","consent":true,"consentVersion"}
POST /api/v1/recover/request {"rid","newPhone","consent":true,"consentVersion"} → 201 {"ticket","status":"pending"}
POST /api/v1/recover/request/status {"ticket"} → {"status":"pending|approved|rejected","rid"?}
GET /api/v1/scan/recovery · POST /api/v1/scan/recovery/:id/images · POST /api/v1/scan/recovery/:id/approve|reject
Hướng dẫn gắn AI quét giấy tờ (d22)
Gắn dịch vụ AI đọc CCCD/hộ chiếu của bất kỳ nhà cung cấp nào chỉ bằng biến môi trường, không sửa mã. Mặc định tắt. Tải bản hướng dẫn đầy đủ: HUONG-DAN-GAN-AI.md; hợp đồng chi tiết trong openapi.yaml (DocAiProviderRequest / DocAiProviderResponse / DocAiProviderError).
- Khi nào gọi: chỉ khi đã bật, khách đã tick đồng ý (d21), điện thoại không tự đọc được mặt trước/trang hộ chiếu, và ảnh đã tải lên (so SHA-256). Một lần mỗi hồ sơ, 10 lần/giờ/tài khoản.
- Cấu hình:
DOCAI_ENABLED=1,DOCAI_URL=https://…(http chỉ cho localhost),DOCAI_API_KEY(gửi Bearer, không bao giờ ghi log/trả về),DOCAI_TIMEOUT_MS(mặc định 8000, 1000–30000),DOCAI_PROVIDER_NAME,DOCAI_FORMAT=json|multipart,DOCAI_MIN_CONFIDENCE(mặc định 0.8). - Yêu cầu gửi đi:
POST DOCAI_URL, headerauthorization: Bearer …,x-request-id; thân{"requestId","docType":"cccd_front|cccd_back|passport","lang","images":[{"side","mime":"image/jpeg","data":"<base64>"}]}(hoặc multipart với tệpimage). - Phản hồi mong đợi:
{"ok":true,"fields":{"fullName","dob":"YYYY-MM-DD","sex":"M|F|X","idNumber","address","nationality","passportNumber","expiry"},"confidence":{"fullName":0.97,…}}. Trường dưới ngưỡng bị bỏ. - Dùng kết quả: chỉ điền ô còn trống (ngày sinh, giới tính); KHÔNG ghi đè ô khách đã gõ; tên/năm sinh lệch → cờ cho nhân viên.
- Lỗi:
{"ok":false,"error":{"code":"unreadable|unauthorized|unsupported_document|too_large|rate_limited|internal"}}; phía TravelQ thêmtimeout,network,bad_response,no_fields. Mọi lỗi/hết giờ đều im lặng – khách tự nhập, vẫn được cấp ID. - Thử:
node tools/docai-mock-server.mjs 8797rồi chạy máy chủ vớiDOCAI_ENABLED=1 DOCAI_URL=http://127.0.0.1:8797/v1/read. Nhật kýkyc.docai_readkhông chứa ảnh, giá trị hay khoá.
HỆ THỐNG QUÉT TRAVELQ
Trang /quet/ dành riêng cho nhân viên TravelQ: quét QR trên thẻ Travel ID (hoặc nhập mã) để xem đầy đủ thông tin du khách và ghi giao dịch. Không dùng API key; nhân viên đăng nhập bằng tài khoản do quản trị tạo ở /admin → Nhân viên quét.
- Vai trò:
scanner(tra cứu, hiện số giấy tờ đầy đủ, ghi giao dịch) vàscanner_basic(không hiện số đầy đủ). - Phiên: cookie
tqid_scanHttpOnly, SameSite=Strict, tối đa 2 giờ, tự thoát sau 30 phút không thao tác. Các lệnh POST cần headerx-csrf-token(trả về khi đăng nhập). Sai mật khẩu 5 lần → khoá 15 phút. - Mã chấp nhận: QR
TQID:TQ482019379, nhập tay có/không dấu cách, mã cũTQ-YYYY-…. QR khác →422 invalid_code. - Khuôn mặt & ảnh giấy tờ (d15): kết quả tra cứu có huy hiệu khuôn mặt (Đã xác minh / Chờ xét duyệt / Chưa xác minh). Nút Xem ảnh giấy tờ (chỉ vai trò
scanner) giải mã ảnh CCCD/hộ chiếu, ghi nhật kýscan.view_docimgvà tự ẩn sau 30 giây. - Xoá ảnh giấy tờ (d17): ảnh lưu mã hoá đến khi du khách yêu cầu xoá ở trang Hồ sơ. Sau khi xoá,
scan/docimgtrả"status":"deleted"; nhật ký ghikyc.document_images_deleted. - Nhật ký: mọi lượt tra cứu, hiện số, ghi giao dịch đều được ghi kèm mã nhân viên; xem ở
/admin. - Mọi phản hồi
Cache-Control: no-store; trình duyệt không lưu dữ liệu du khách.
POST /api/v1/scan/login {"login":"0912345678","password":"…"} → Set-Cookie tqid_scan + {"csrfToken":"…"}
POST /api/v1/scan/lookup {"code":"TQID:TQ482019379","via":"camera"} → {"traveller":{…, "transactions":[…]}, "canReveal":true}
POST /api/v1/scan/reveal {"tqid":"TQ482019379"} → {"numberStatus":"ok"|"not_stored", "number":"…"|null, "masked":"…"}
POST /api/v1/scan/docimg {"tqid":"TQ482019379"} → {"status":"ok"|"none"|"not_stored"|"deleted"|"key_missing"|"decrypt_failed", "images":[{"side":"front","label":"…","image":"data:image/jpeg;base64,…"}]}
POST /api/v1/scan/note {"tqid":"TQ482019379","type":"other","note":"Nhận phòng"} → 201
POST /api/v1/me/document-images/delete {"confirm":true} (du khách) → {"ok":true,"deleted":["front","back"],"deletedAt":"…"}
POST /api/v1/me/wizard/doc-read (AI đọc giấy tờ – mặc định tắt, xem #gan-ai) → 503 docai_unavailable
POST /api/v1/scan/logout
GET /api/v1/admin/staff · POST /api/v1/admin/staff · PATCH /api/v1/admin/staff/:id · GET /api/v1/admin/scanlog
SDK JAVASCRIPT
Tệp /sdk/travelq-id.js (không phụ thuộc thư viện, chạy trên trình duyệt, React Native, Node 18+). Gồm TravelQId.App (tự làm mới token, gộp refresh song song), TravelQId.Partner , TravelQId.OIDC (PKCE, kiểm id_token qua JWKS), TravelQId.verifyWebhook() và TravelQId.isValidTqid() kiểm tra ký tự kiểm tra ngay trên máy. OIDC/webhook cần WebCrypto (có sẵn trên trình duyệt HTTPS và Node 18+; React Native truyền { crypto } tương thích).
// App TravelQ
const tq = new TravelQId.App({
baseUrl: '{{BASE}}', deviceName: 'iPhone của An',
storage: { get: () => secureStore.get('tq'), set: (t) => secureStore.set('tq', t) }, // Keychain/Keystore
onLogout: () => navigate('Login'),
});
await tq.requestOtp('0912345678');
await tq.register({ login: '0912345678', password: 'Matkhau2026', otp: '482913' });
await tq.saveStep('personal', { kind: 'Công dân Việt Nam', name: 'Nguyễn Minh An', dob: '12/04/1995', gender: 'Nam', nationality: 'VN' });
await tq.saveStep('document', { cccd: '000000000201' });
await tq.saveStep('contact', { phone: '0912345678', street: '12 Lê Lợi', ward: 'Phường Hải Châu', prov: 'Đà Nẵng' });
await tq.saveStep('preferences', { interests: ['am-thuc'], languages: ['vi'] });
const { tqid, card } = await tq.finish({ terms: true, travel: true, share: false });
const url = await tq.embedUrl({ state: 'abc123' }); // nếu muốn mở /embed trong webview
// Máy chủ dịch vụ (Node)
const p = new TravelQId.Partner({ baseUrl: '{{BASE}}', apiKey: process.env.TQ_KEY });
if ((await p.verify(tqid)).valid) await p.recordTransaction(tqid, { type: 'order', ref: 'DH-881', amount: 320000 });
TravelQId.isValidTqid('TQ482019379'); // true/false, không gọi mạng
// "Đăng nhập bằng TravelQ ID" (OIDC + PKCE) – app/website bất kỳ
const oidc = new TravelQId.OIDC({ baseUrl: '{{BASE}}', clientId: 'tqc_…', redirectUri: 'travelq://oauth/callback' });
const { url, state, nonce, verifier } = await oidc.authorizeUrl({ scope: 'openid profile phone offline_access' });
// mở url bằng trình duyệt hệ thống; khi quay về redirectUri:
const tokens = await oidc.exchangeCode({ code, verifier }); // {access_token, id_token, refresh_token}
const claims = await oidc.verifyIdToken(tokens.id_token, { nonce }); // kiểm chữ ký JWKS, iss, aud, exp
const me = await oidc.userinfo(tokens.access_token);
const next = await oidc.refresh(tokens.refresh_token);
// Máy chủ dịch vụ: liên kết hồ sơ + webhook
await p.linkProfile(claims.sub, { externalId: 'HV-00123', label: 'Hội viên' });
await TravelQId.verifyWebhook(rawBody, req.headers['x-tqid-signature'], process.env.TQ_WEBHOOK_SECRET); // true/false
MÃ LỖI
| HTTP | code | Ý nghĩa |
|---|---|---|
| 400 | bad_json, invalid_tqid, otp_invalid, otp_expired, recover_expired | Yêu cầu sai, ID sai định dạng/ký tự kiểm tra, OTP sai/hết hạn |
| 401 | unauthenticated, token_expired, invalid_token, invalid_refresh, refresh_reused, session_revoked, invalid_credentials, invalid_api_key, invalid_data_key | Chưa/không còn đăng nhập hoặc key sai |
| 403 | scope_required, consent_required, api_key_revoked, origin_not_allowed, csrf, bad_origin, insufficient_scope, data_key_revoked, ip_not_allowed, browser_not_allowed | Không có quyền |
| 404 | not_found, no_id | Không tìm thấy / chưa có ID |
| 409 | login_taken, step_order, duplicate_document, already_issued, external_id_taken | Xung đột dữ liệu |
| 422 | validation, consent_required, field_locked | Dữ liệu chưa hợp lệ – xem errors |
| 409 / 422 / 503 | request_pending, docai_not_needed, image_mismatch, images_missing, docai_failed, docai_unavailable | d22: lấy lại tài khoản / AI đọc giấy tờ (lỗi AI được ứng dụng bỏ qua im lặng) |
| 423 | id_locked | ID đang bị khoá |
| 429 | rate_limited, otp_cooldown, otp_too_many, recover_locked | Gọi quá nhiều – đọc header Retry-After |
BẢO MẬT & GIỚI HẠN
- Mật khẩu băm scrypt; token truy cập ký HMAC; refresh token và API key chỉ lưu bản băm.
- Mặc định số giấy tờ không lưu dạng đầy đủ: chỉ bản băm (để chặn một giấy tờ cấp nhiều ID) và bản che. Chỉ khi quản trị bật
DATA_ENCRYPTION_KEYmới lưu thêm bản mã hoá AES-256-GCM, chỉ trả cho khoá dữ liệu nội bộ córead:documents_full(chi tiết). - Dịch vụ chỉ thấy dữ liệu trong phạm vi du khách cho phép; mọi lượt tra cứu, ghi giao dịch, thao tác quản trị đều ghi nhật ký.
- Giới hạn tần suất theo IP, theo tài khoản và theo API key; CORS chỉ mở cho các origin được cấu hình.
- id_token ký RS256 (khoá riêng lưu mã hoá AES-GCM); access token OIDC ký HMAC; code OIDC 5 phút, một lần; PKCE S256 bắt buộc; redirect URI phải khớp chính xác.
- Webhook ký HMAC-SHA256 có dấu thời gian; payload không chứa dữ liệu cá nhân.
- Bản thử nghiệm: OTP mô phỏng (cả “Tiếp tục với Gmail”), quét CCCD/VNeID mô phỏng, tài khoản demo dùng chung (hiển thị mã trên màn hình), dữ liệu demo, chưa đối chiếu giấy tờ với nguồn chính thức.