Sign-up Email OTP
This document describes the email OTP verification at sign-up: before an account is created, the user must prove they can receive email at the given address by entering a 6-digit code sent via Resend. This prevents dummy or fake email addresses from being used for registration. Sign-up and login OTP are email only (no SMS/MSG91 in the current codebase).
Overview
| Area | Behaviour |
|---|---|
| Flow | 3 steps: (1) Enter email → Send code, (2) Enter 6-digit OTP → Verify, (3) Complete registration form with email locked. |
| Backend | New table signup_otps; endpoints POST /register/send-otp, POST /register/verify-otp; POST /register requires a short-lived signup token (unless bypassed). |
| OTP is sent using the same Resend integration as password reset and login 2FA. | |
| Clients | Web (customer register), customer Android app, and Windows shopkeeper app all use the same 3-step flow. |
| Login OTP | Login can also use email OTP (POST /login/request-otp, POST /login/verify-otp). OTP is email only (no SMS). See API Documentation. |
User flow (all clients)
-
Step 1 – Email and send OTP
- User enters email (and on web, selects role: customer or shopkeeper).
- User taps/clicks Send verification code.
- Backend checks that the email is not already registered, then sends a 6-digit OTP to that address via Resend.
-
Step 2 – Verify OTP
- UI shows: “We sent a 6-digit code to user@example.com. Enter it below.”
- User enters the 6-digit code and taps Verify.
- Backend verifies the code and returns a signup token (and the verified email). No account is created yet.
-
Step 3 – Complete registration
- Email is shown as verified and read-only.
- User fills the rest of the form (name, username, phone, password, and for shopkeeper: shop name, address, location).
- On submit, the client sends signup_token with the registration payload. Backend validates the token and that the body email matches the token, then creates the user.
Backend
Database
- Table
signup_otpsemail_lower(PK),code_hash,expires_at- One active OTP per email; sending a new code replaces the previous one.
- OTP expires after 10 minutes.
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /register/send-otp | Body: { "email": "user@example.com" }. Validates format, rejects if email already registered, generates 6-digit OTP, stores hash, sends email via Resend. Returns 200 with { "message": "Verification code sent to your email." }. |
POST | /register/verify-otp | Body: { "email", "code": "123456" }. Verifies code and expiry, deletes OTP (one-time use), issues signup token. Returns 200 with { "signup_token": "...", "email": "user@example.com" }. |
POST | /register | Same body as before, plus signup_token. Backend validates token and requires req.Email to match the token’s email, then runs existing validation and creates the user. |
All three are in the same auth rate-limit group (e.g. 50 requests per 15 min per IP).
Signup token
- Short-lived JWT (e.g. 15 minutes) with purpose
signup_pendingandemailin claims. - Issued only by
POST /register/verify-otpafter successful OTP verification. - Valid only for one
POST /register; backend does not store a separate “used” state (token expiry is sufficient).
Email template
- SendSignupOTPEmail(to, code) – Subject: “Your Qprint Sign-up Code”. Body: 6-digit code and “Valid for 10 minutes.” Uses the same Resend configuration as other emails (
RESEND_API_KEY,FROM_EMAIL).
Bypass (development / testing)
| Variable | Description |
|---|---|
SKIP_SIGNUP_OTP | If set to true, POST /register does not require signup_token. Use only in dev/test environments. |
Configuration
- Resend: Same as password reset and referral invites. Set
RESEND_API_KEYandFROM_EMAIL(and optionallyFROM_NAME). - JWT: Signup tokens use the same
JWT_SECRETas login tokens.
Clients
Web (customer register)
- Page:
/register - API:
registerSendOtp(email),registerVerifyOtp(email, code)→ thenPOST /registerwithsignup_tokenin the payload. - UI: Step 1 = email + role + “Send verification code”; Step 2 = OTP input + “Verify” + “Use a different email”; Step 3 = full form with “Email verified: …” (read-only) and “Create Account”.
Customer app (Android)
- Screen: Register screen.
- API:
ApiService.registerSendOtp,ApiService.registerVerifyOtp,ApiService.register(..., signupToken). - UI: Same 3 steps; step 3 shows full form with email as “Email (verified)” read-only.
Shopkeeper app (Windows)
- Screen: Register screen.
- API: Same pattern:
registerSendOtp,registerVerifyOtp,register(..., signupToken). - UI: Same 3 steps; step 3 is the full shopkeeper form (shop name, address, location, etc.) with email read-only.
Security and behaviour
- Rate limiting: Auth group applies to
/register/send-otpand/register/verify-otp(e.g. per-IP limit). - OTP: 6 digits, cryptographically random; stored as bcrypt hash; single use; 10-minute expiry.
- Already registered: If the email exists in
users,POST /register/send-otpreturns 400 “This email is already registered.” and no OTP is sent. - Invalid/expired OTP:
POST /register/verify-otpreturns 400 “Invalid or expired code.” without revealing whether the email exists. - Token expired: If the user does not submit
POST /registerwithin the token lifetime, they must run “Send code” and “Verify” again.
Related
- Environment variables – Resend and JWT configuration.
- Referral (Refer & Earn) – Optional
referral_codeat registration (step 3). - Design note:
project-docs/SIGNUP_EMAIL_OTP_DESIGN.md(design-only document).