Send Phone OTP
Initiate a 6-digit phone OTP. Creates a Challenge record and dispatches an SMS code via the active SMS provider (admin-managed). Returns challengeId for verify/resend.
POST /api/v1/auth/send-otp — 🌐 Public · Rate limit: 3 req / 10 min
Creates a Challenge record and dispatches a 6-digit code via SMS to the given phone number. Returns the challengeId that the client passes to /verify-otp, /resend-otp, and /challenge/{id}.
The SMS is sent via the active SMS provider (admin-managed via external.sms.active_provider; failover handled server-side). Vendor identity stays in admin — this endpoint guarantees delivery via whichever provider is active.
Purposes (OtpPurpose enum)
| Purpose | Auth required | Notes |
|---|---|---|
verify-phone-fan | ❌ Public | Signup flow — account not yet created |
verify-phone-profile | 🔑 Bearer | Account settings → add or change phone |
2fa-setup | 🔑 Bearer | Enabling 2FA via SMS (paired with TOTP setup) |
login-2fa | ❌ Public | Uses tempToken from /auth/login, not session |
Request
Body — SendOtpDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
phone | string (E.164) | ✓ | max 20 chars; regex ^\+[1-9]\d{7,14}$ | E.g., +15551234567 — plus prefix mandatory |
purpose | OtpPurpose | ✓ | IsEnum(OTP_PURPOSES) | One of the four purposes above |
Response
200 OK — ApiResponseOf<ChallengeDispatchResponseDto>
{
"success": true,
"data": {
"challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expiresAt": "2026-04-29T20:15:00.000Z",
"attemptsRemaining": 5
}
}| Field | Type | Notes |
|---|---|---|
challengeId | string (UUID) | Pass to /verify-otp, /resend-otp, /challenge/{id} |
expiresAt | string (ISO 8601) | TTL = auth.otp_ttl_minutes (admin-managed config) |
attemptsRemaining | number | Initial = auth.otp_max_attempts (admin-managed config) |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | auth.otp.send.rate_limit | Per-phone rate limit exhausted (auth.otp_per_phone_max_per_hour) |
400 | (DTO validation) | Phone not E.164, or invalid purpose enum |
429 | (throttle) | Endpoint rate limit exceeded (3 req / 10 min) |
Side effects
- Per-phone rate-limit check — config-driven (
auth.otp_per_phone_max_per_hour). - Generate 6-digit code; bcrypt-hash it (
Challenge.codeHash). - Insert
Challengerow withpurpose,phone,codeHash,expiresAt,attempts = 0,resendCount = 0. - Dispatch SMS to phone via the active SMS provider (admin-managed via
external.sms.active_provider). Failover is handled server-side. - Audit log:
auth.otp.sent(with masked phone — last 4 digits only).
Code samples
curl -X POST https://api.bio.re/api/v1/auth/send-otp \
-H 'Content-Type: application/json' \
-d '{
"phone": "+15551234567",
"purpose": "verify-phone-fan"
}'type OtpPurpose = 'verify-phone-fan' | 'verify-phone-profile' | '2fa-setup' | 'login-2fa';
type ChallengeDispatch = {
challengeId: string;
expiresAt: string;
attemptsRemaining: number;
resendCount?: number;
};
async function sendOtp(phone: string, purpose: OtpPurpose): Promise<ChallengeDispatch> {
const res = await fetch('https://api.bio.re/api/v1/auth/send-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, purpose }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Send OTP failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useMutation } from '@tanstack/react-query';
export function useSendOtp() {
return useMutation({
mutationFn: async (input: { phone: string; purpose: OtpPurpose }) => {
const res = await fetch('/api/v1/auth/send-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Send OTP failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as ChallengeDispatch;
},
});
}Try it
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/auth/send-otp" \ -H "Content-Type: application/json" \ -d '{ "phone": "+15551234567", "purpose": "verify-phone-fan" }'{
"success": true,
"data": {
"challengeId": "007cfdcc-a46d-4340-a4c6-216ec2e4009c",
"expiresAt": "2019-08-24T14:15:22Z",
"attemptsRemaining": 5,
"resendCount": 0
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/auth/auth.controller.ts | 315–339 (sendOtp) |
| DTO (request) | apps/api-core/src/modules/auth/dto/send-otp.dto.ts | 15–25 (SendOtpDto), 11–12 (OTP_PURPOSES) |
| DTO (response) | apps/api-core/src/modules/auth/dto/challenge.dto.ts | 7–22 (ChallengeDispatchResponseDto) |
| Service | apps/api-core/src/modules/auth/challenge.service.ts | sendOtp() |
| Prisma model | packages/prisma/prisma/schema.prisma | Challenge |