BIO.RE
Authentication

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)

PurposeAuth requiredNotes
verify-phone-fan❌ PublicSignup flow — account not yet created
verify-phone-profile🔑 BearerAccount settings → add or change phone
2fa-setup🔑 BearerEnabling 2FA via SMS (paired with TOTP setup)
login-2fa❌ PublicUses tempToken from /auth/login, not session

Request

Body — SendOtpDto

FieldTypeRequiredValidationNotes
phonestring (E.164)max 20 chars; regex ^\+[1-9]\d{7,14}$E.g., +15551234567 — plus prefix mandatory
purposeOtpPurposeIsEnum(OTP_PURPOSES)One of the four purposes above

Response

200 OKApiResponseOf<ChallengeDispatchResponseDto>

{
  "success": true,
  "data": {
    "challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "expiresAt": "2026-04-29T20:15:00.000Z",
    "attemptsRemaining": 5
  }
}
FieldTypeNotes
challengeIdstring (UUID)Pass to /verify-otp, /resend-otp, /challenge/{id}
expiresAtstring (ISO 8601)TTL = auth.otp_ttl_minutes (admin-managed config)
attemptsRemainingnumberInitial = auth.otp_max_attempts (admin-managed config)

Errors

HTTPcode / i18nKeyReason
400auth.otp.send.rate_limitPer-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

  1. Per-phone rate-limit check — config-driven (auth.otp_per_phone_max_per_hour).
  2. Generate 6-digit code; bcrypt-hash it (Challenge.codeHash).
  3. Insert Challenge row with purpose, phone, codeHash, expiresAt, attempts = 0, resendCount = 0.
  4. Dispatch SMS to phone via the active SMS provider (admin-managed via external.sms.active_provider). Failover is handled server-side.
  5. 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

POST
/api/v1/auth/send-otp

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

SourcePathLines
Controllerapps/api-core/src/modules/auth/auth.controller.ts315–339 (sendOtp)
DTO (request)apps/api-core/src/modules/auth/dto/send-otp.dto.ts15–25 (SendOtpDto), 11–12 (OTP_PURPOSES)
DTO (response)apps/api-core/src/modules/auth/dto/challenge.dto.ts7–22 (ChallengeDispatchResponseDto)
Serviceapps/api-core/src/modules/auth/challenge.service.tssendOtp()
Prisma modelpackages/prisma/prisma/schema.prismaChallenge

On this page