TravelQ IDTài liệu API
openapi.yaml
BẢN THỬ NGHIỆM · OTP mô phỏng, dữ liệu demo – API có thể thay đổi trước khi chính thức
API v1 · JSON

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ọiXác thựcNhóm endpoint
Ứng dụng TravelQ (iOS/Android)Authorization: Bearer <accessToken> + refresh token/auth/*, /me/*
Trang web TravelQ IDCookie HttpOnly + header X-CSRF-Token/auth/*, /me/*
Máy chủ dịch vụ TravelQX-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)

  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} }
  2. 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"}
  3. Đă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
  4. 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":…}}
  5. 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"}}
  6. 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èm errors theo 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.
  7. 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èm errors theo 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.
  8. Hoàn tất – hiển thị nội dung consent.text từ /meta, chỉ gửi khi người dùng đã tích đồng ý.
    curl -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":{…}}
    App lưu tqid và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ải qrSvgUrl kèm Bearer).
  9. 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 refreshToken trong Keychain (iOS) / Keystore – EncryptedSharedPreferences (Android); accessToken có thể giữ trong bộ nhớ.
  • accessToken sống 15 phút. Khi API trả 401 token_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_reused và 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:

  1. /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.
  2. /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ạngPhạ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ênprofile
① nameHọ và tênchuỗi, 2–80 ký tựprofile
② birthYearNăm sinhyyyy (số), trong 120 năm gần nhấtprofile
dobNgày sinh (tuỳ chọn)dd/mm/yyyy (OIDC birthdate = yyyy-mm-dd)profile
genderGiới tính (tuỳ chọn)"Nam" | "Nữ" | "Khác"profile
nationalityQuốc tịch (tự lấy theo quốc gia nếu không gửi)mã ISO-3166 alpha-2 (VN, KR…)profile
③ cccdSố CCCD (12 số) – công dân Việt Namchỉ nhận vào; lưu băm + cccdMaskeddocument
③ passportSố hộ chiếu (chữ + số) – khách quốc tếchỉ nhận vào; lưu băm + passportMaskeddocument
passportCountryNước cấp hộ chiếu (tuỳ chọn, mặc định = quốc gia)ISO alpha-2document
passportExpHạn hộ chiếu (tuỳ chọn)dd/mm/yyyydocument
⑤ phoneSố điện thoại0xxxxxxxxx (VN) hoặc +mã nước (OIDC phone_number dạng E.164)phone
emailEmail (tuỳ chọn)tuỳ chọnemail
④ streetĐịa chỉ (số nhà, đường…)chuỗi, tối đa 120 ký tựaddress
wardPhường/Xã (tuỳ chọn)tuỳ chọnaddress
⑥ provTỉ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ậpaddress
⑦ countryQuốc giaISO-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 3emergency
interests, languagesSở thích, ngôn ngữ (tuỳ chọn, riêng của TravelQ)mảng mã trong /metatravel

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
  1. Đă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ận client_id tqc_…, API key tqk_… (với confidential đây cũng là client_secret) và webhook secret – chỉ hiển thị một lần.
  2. 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):
    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
    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 ý /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=….
  3. Đổ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
  4. 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.
  5. UserInfo: GET /oauth/userinfo với Authorization: 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.
  6. 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ệnKhi nào
id.issuedNgười dùng vừa được cấp TravelQ ID (gửi cho ứng dụng first-party)
profile.updatedNgười dùng sửa hồ sơ; changed liệt kê tên trường
id.locked / id.unlockedQuản trị khoá / mở khoá ID – nên chặn giao dịch tương ứng
pingNú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

  1. Giao diện app (đã đăng nhập) gọi GET /api/v1/auth/session bằng token của người dùng. Nếu hasTravelId=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ửi travelq-id:issued cho app.
  2. 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ọi GET /data/travellers/{travelId}, hoặc đồng bộ định kỳ GET /data/travellers?cursor=…. Không tin tqid do giao diện gửi lên mà chưa đối chiếu bằng Data API.
  3. Đẩy đơn đặt chỗ thành giao dịch: POST /data/travellers/{travelId}/transactions với ref = mã đơn của app (gửi lại không bị trùng).
  4. 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ó trong user.sub của kết quả đăng nhập/đăng ký và trong account.sub củ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 theo sub. Người dùng cũ chưa có cặp này: tra POST /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 viCho phép
read:travellersHồ 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:idsChỉ 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:eventsNhật ký sự kiện và webhook
read:documents_fullSố 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_unavailable và không gọi dịch vụ ngoài.
  • Cờ: TRAVELQ_DOCSCAN=0 tắt đọc thầm; TRAVELQ_DOCSCAN_LIVE=1 bật lại máy quét trực tiếp d16/d17 (thử nghiệm). GET /me/wizard trả 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-image vớ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/face vớ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: 401 thiếu/sai khoá · 403 scope_not_allowed khoá không có quyền · 409 face_not_enrolled khá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ò scanner xem ả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: demoCode chỉ trả khi OTP_PROVIDER là 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).

  1. 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.
  2. 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).
  3. Yêu cầu gửi đi: POST DOCAI_URL, header authorization: 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ệp image).
  4. 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ỏ.
  5. 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.
  6. Lỗi: {"ok":false,"error":{"code":"unreadable|unauthorized|unsupported_document|too_large|rate_limited|internal"}}; phía TravelQ thêm timeout, 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.
  7. Thử: node tools/docai-mock-server.mjs 8797 rồi chạy máy chủ với DOCAI_ENABLED=1 DOCAI_URL=http://127.0.0.1:8797/v1/read. Nhật ký kyc.docai_read khô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_scan HttpOnly, 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 header x-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_docimg và 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/docimg trả "status":"deleted"; nhật ký ghi kyc.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

HTTPcodeÝ nghĩa
400bad_json, invalid_tqid, otp_invalid, otp_expired, recover_expiredYêu cầu sai, ID sai định dạng/ký tự kiểm tra, OTP sai/hết hạn
401unauthenticated, token_expired, invalid_token, invalid_refresh, refresh_reused, session_revoked, invalid_credentials, invalid_api_key, invalid_data_keyChưa/không còn đăng nhập hoặc key sai
403scope_required, consent_required, api_key_revoked, origin_not_allowed, csrf, bad_origin, insufficient_scope, data_key_revoked, ip_not_allowed, browser_not_allowedKhông có quyền
404not_found, no_idKhông tìm thấy / chưa có ID
409login_taken, step_order, duplicate_document, already_issued, external_id_takenXung đột dữ liệu
422validation, consent_required, field_lockedDữ liệu chưa hợp lệ – xem errors
409 / 422 / 503request_pending, docai_not_needed, image_mismatch, images_missing, docai_failed, docai_unavailabled22: 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)
423id_lockedID đang bị khoá
429rate_limited, otp_cooldown, otp_too_many, recover_lockedGọ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_KEY mớ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.