Skip to main content

Link thẻ bằng OTP SĐT

Trang này dành cho FE trang web công khai mở từ Zalo OA: người dùng không đăng nhập, chỉ chứng minh hai thứ:

  1. SĐT là của mình — card-core gửi OTP tới số đó.
  2. Thẻ là của mình — số thẻ đầy đủ + PIN in kèm thẻ, như luồng tự link thẻ bằng số thẻ + PIN.

Xong thì thẻ được chia sẻ (card_users) cho staff mang SĐT đó trong bảng corporate_staffs của card-core. SĐT chưa có staff nào thì card-core tự tạo staff trong công ty sở hữu thẻ — chỉ sau khi số thẻ + PIN đúng — rồi link thẻ vào staff đó.

Người được link dùng thẻ thế nào, công ty bỏ link ra sao: xem Chia sẻ thẻ & tự link thẻ.

Tạm thời: không hỏi Store hay backend corporate

Luồng này chỉ đọc và ghi corporate_staffs của card-core. Không tra tài khoản Store, không tạo staff bên backend corporate. Hệ quả ở §6.

Base URL staginghttps://api-staging-core-card.uts.network
Base URL localhttp://localhost:3000
HeaderContent-Type: application/json — không có Authorization
Spec máy đọc đượcdocs/openapi.generated.json trong repo card-core, tag card-link

Cả 4 route đều public. Thứ cho phép là link_token mà bước 2 trả về.


1. Flow​

Nhập SĐT ──► [1] POST /card-link/otp ──► SMS mã 6 số
│
Nhập mã ──► [2] POST /card-link/otp/confirm ──► link_token (sống 5 phút)
│
[3] POST /card-link/accounts ──► accounts[]
│
┌────────────┼──────────────────┐
0 staff 1 staff nhiều staff
hỏi họ tên không hỏi gì cho chọn user_id
└────────────┼──────────────────┘
▼
Nhập số thẻ + PIN ──► [4] POST /card-link/cards ──► thẻ đã link
(SĐT chưa có staff: card-core tạo staff
sau khi PIN đúng, rồi mới link)

2. Endpoint​

2.1 Gửi mã OTP​

POST /card-link/otp
{ "phone": "0901234567" }

200

{
"challenge_id": "36df0bc0-4189-4ee5-ae2f-2c9eddd9407a",
"purpose": "link_card_by_phone",
"method": "sms_otp",
"destination_masked": "****4567",
"expires_at": "2026-10-13T08:07:50.984Z",
"attempts_remaining": 5
}
  • SĐT gõ kiểu nào cũng được (0901234567, +84 901 234 567), nhưng chỉ số di động Việt Nam (10 số). Số nước ngoài → 422 phone_not_supported.
  • Bước này không tra gì cả: mã được gửi dù SĐT đã có staff hay chưa.
  • "Gửi lại mã" = gọi lại API này; mỗi lần là một challenge_id mới.

2.2 Xác nhận mã​

POST /card-link/otp/confirm
{ "challenge_id": "36df0bc0-…", "code": "123456" }

200

{ "link_token": "7c0dda7b…(64 ký tự hex)", "expires_at": "2026-10-13T08:12:51.099Z" }

link_token sống OTP_GRANT_TTL_SECONDS (mặc định 5 phút). Hết hạn thì làm lại từ bước 2.1. Giữ token trong bộ nhớ (biến JS) — không localStorage, không đưa lên URL.

2.3 Staff mang SĐT này​

Chỉ đọc, không tiêu token — gọi lại bao nhiêu lần cũng được.

POST /card-link/accounts
{ "link_token": "7c0dda7b…" }

200

{
"phone_masked": "****4567",
"accounts": [
{ "user_id": "4242", "name": "Lê Hoàng Tuấn", "email_masked": "t***@acme.vn" },
{ "user_id": "phone:+84901234567", "name": "Lê Hoàng Tuấn", "email_masked": null }
]
}
accountsMàn tiếp theoBước 2.4 gửi thêm
[] — SĐT chưa có staffForm họ tên + số thẻ + PINfirst_name, last_name
1 phần tửForm số thẻ + PIN—
Nhiều phần tử (một SĐT nhiều tài khoản — có thật)Chọn người → form số thẻ + PINuser_id đã chọn

SĐT được so theo chữ số, nên staff lưu +84 / 0912… hay 84 / 912… đều khớp.

Tiêu token khi thành công.

POST /card-link/cards
{
"link_token": "7c0dda7b…",
"pan": "9686860012345678",
"pin": "246810",
"user_id": "4242",
"first_name": "Tuấn",
"last_name": "Lê Hoàng"
}
FieldBắt buộcMô tả
link_token✅Từ bước 2.2
pan✅16 chữ số in trên thẻ
pin✅4–8 chữ số in kèm thẻ
user_idKhi SĐT có nhiều staffMột user_id từ bước 2.3
first_name, last_nameKhi SĐT chưa có staff1–50 ký tự mỗi field

200

{
"user_id": "phone:+84901234567",
"staff_created": true,
"card": {
"card_id": "3c0e0e0e-…",
"display_name": "Thẻ taxi KD",
"pan_last4": "5678",
"status": "active",
"access": "linked",
"balance": { "available": "1200000.00", "…": "…" },
"…": "…"
}
}
  • card cùng shape với GET /my/cards/{card_id}.
  • staff_created: true = SĐT chưa có staff, card-core vừa tạo staff trong công ty của thẻ, với user_id dạng phone:+84… (xem §6).
  • Công ty sở hữu thẻ nhận event card.user_linked (via: card_pin) và bỏ link được.

3. Token: khi nào còn, khi nào hết​

Kết quả bước 2.4Token
Thành côngHết — muốn link thẻ khác thì làm lại từ bước 2.1
Bất kỳ lỗi nào (sai PIN, chưa chọn staff, thiếu tên…)Còn — sửa rồi gửi lại, không cần mã mới

Sai PIN vẫn bị đếm dù token còn (xem §5). Ô số thẻ + PIN vẫn xoá sau mỗi lần gửi; người dùng nhập lại khi thử lại.


4. Mã lỗi​

Body lỗi: { "code": "...", "message": "...", "details"?: {...} }.

BướcHTTPcodeKhi nàoFE làm gì
mọi bước422validation_failedSai định dạng fieldBáo ở ô nhập
mọi bước429—Quá giới hạn theo IP"Thao tác quá nhanh, thử lại sau"
2.1422phone_not_supportedKhông phải số di động VN"Chỉ hỗ trợ số di động Việt Nam"
2.1429otp_velocity_exceeded5 mã tới một SĐT trong 10 phútBáo thử lại sau
2.1502otp_delivery_failedKhông gửi được SMSCho bấm gửi lại
2.2422otp_invalidSai mã — details.attempts_remainingCho nhập lại
2.2422otp_locked / otp_expired / otp_not_pendingHết lượt / hết hạn / đã dùngQuay lại bước 2.1
2.3, 2.4422otp_grant_invalidToken sai, hết hạn hoặc đã dùngQuay lại bước 2.1
2.4409account_choice_requiredSĐT có nhiều staff mà không gửi user_idVề màn chọn người
2.4422account_not_on_phoneuser_id không phải staff của SĐTCho chọn lại
2.4422name_requiredSĐT chưa có staff mà thiếu họ tênHỏi họ tên
2.4422card_pin_invalidSai số thẻ hoặc sai PIN"Số thẻ hoặc PIN không đúng"
2.4409card_locked_too_many_attemptsThẻ đang khoá — details.locked_untilBáo thử lại sau giờ đó
2.4429too_many_pin_attemptsSĐT đã sai 5 lần trong 1 giờBáo thử lại sau
2.4422card_has_no_pinThẻ không có PIN in kèmHướng dẫn liên hệ admin công ty
2.4422card_not_activeThẻ bị khoá / hết hạn — details.statusHướng dẫn liên hệ admin công ty

account_choice_required, account_not_on_phone và name_required được trả trước khi kiểm PIN, nên không tốn lượt PIN.


5. Chống lạm dụng​

LuậtHệ quả
Mỗi IP: 5 lần gửi mã / phút; 10 lần / phút cho mỗi route còn lại429
Mỗi SĐT: 5 mã / 10 phút429 otp_velocity_exceeded
Chỉ số di động VNKhông gửi SMS ra số quốc tế (chống SMS toll fraud)
Mỗi mã: 5 lần nhậpotp_locked
Sai PIN tính theo SĐT: 5 lần sai / 1 giờSĐT bị tạm chặn (too_many_pin_attempts)
Sai PIN trên một thẻ: 3 lầnThẻ khoá 15 phút — khoá dùng chung với tự link thẻ và merchant
Staff chỉ được tạo sau khi số thẻ + PIN đúngKhông tạo staff rác cho SĐT không cầm thẻ
SĐT, mã, token, số thẻ, PIN

card-core không ghi body của các route /card-link/* vào activity log, và không lưu số thẻ hay PIN ở đâu cả. FE cũng vậy: không lưu, không gửi lên analytics hay crash log, xoá ô số thẻ + PIN ngay sau khi gửi.


6. Staff tạo ở card-core​

SĐT chưa có dòng nào trong corporate_staffs thì card-core thêm một dòng:

CộtGiá trị
user_idphone: + SĐT E.164, ví dụ phone:+84901234567
corporate_idCông ty sở hữu thẻ vừa được chứng minh
country_calling_code, phone_number84, 901234567
first_name, last_nameHọ tên gửi ở bước 2.4
role, activeSTAFF, true
  • user_id suy ra từ SĐT, nên gửi lại hay hai request chạy song song cũng chỉ tạo một dòng.
  • Tiền tố phone: không bao giờ trùng một user id Store (luôn là số).
  • SĐT đã có staff (kể cả staff của công ty khác, hay đã nghỉ) thì không tạo gì thêm: thẻ được link vào staff đó. Một user chỉ có một dòng staff, và card-core không ghi đè dòng cache từ backend corporate.
Hệ quả cần biết
  • Staff phone:+84… chưa đăng nhập được ở đâu: app dùng user id Store, nên người này chưa thấy thẻ trong app cho tới khi phone:+84… được gắn với user Store thật (đổi user_id trong corporate_staffs và card_users). Màn thành công nên báo "Đã liên kết thẻ", đừng hướng dẫn mở app để xem thẻ.
  • Backend corporate không biết staff này. Nó không có trong danh sách nhân viên trên portal corporate.
  • Corporate admin vẫn thấy người được link trên thẻ (GET /cards/{card_id}/users, user_id dạng phone:+84…) và bỏ link được.

7. Cấu hình & kiểm tra​

  • Thêm origin của trang web vào biến CORS_ORIGINS của card-core.
  • Các route này chỉ có trên staging sau khi bản card-core mới được deploy. Chưa có bộ API test Bruno.
  • Staging/dev: card-core ghi mã OTP vào log (non-production OTP for ****4567: …) để tester không cầm đúng SĐT vẫn đi hết luồng. Production thì không.

8. Chưa có​

Hạng mụcTình trạng
Gắn staff phone:+84… với user Store thậtChưa có — tới lúc đó người được link chưa thấy thẻ trong app
Đồng bộ staff phone:+84… sang backend corporateChưa có
Tra tài khoản Store theo SĐTKhông làm (tạm thời) — chỉ dùng corporate_staffs của card-core
Link nhiều thẻ trong một lần xác thựcChưa có — mỗi link_token link được một thẻ