Verify OTP
/api/v1/users/auth/verify-otpExchanges a valid 6-digit OTP for a 15-minute access JWT and a refresh token (sliding 30 days, absolute 90 days). Sets Cache-Control: no-store on the response.
https://api.care360-next.carevalidate.com/api/v1/users/auth/verify-otphttps://api-staging.care360-next.carevalidate.com/api/v1/users/auth/verify-otpProvide exactly one of email or phoneNumber (the same identifier used with /send-otp) plus the 6-digit code.
Headers
cv-api-keystringrequiredYour unique API key for authentication.
Content-TypestringrequiredMust be application/json.
Request Body
emailstringoptionalPatient's email address. Required if phoneNumber is not provided.
patient@example.comphoneNumberstringoptionalE.164-formatted phone number. Required if email is not provided.
+15551234567codestringrequiredThe 6-digit numeric OTP delivered by /send-otp. Must match the regex ^\d{6}$.
123456Cross-field rules: exactly one of email or phoneNumber must be provided.
Behavior
- Resolves the organization from
cv-api-key. - Looks up the user (scoped to the organization). If no user is found, the server still performs a uniform-timing claim against a dummy user id and rejects with
Invalid credentials. - Atomically claims an attempt against
UserAuthOtpusing a single conditionalUPDATE ... RETURNING(no read-then-increment race). Succeeds only if the row is unused, not expired, and belowmaxAttempts. - SHA3-512-hashes the supplied
codeand compares to the stored hash. Mismatch →Invalid verification code(the attempt was already counted). - On match, performs a CAS update to mark the OTP
usedAt. If a concurrent request already won, returnsVerification code already used. - Deletes all OTP rows for the user (best effort).
- Looks up the user's
OrganizationAccessrole and issues a new refresh-token family.
Token Issuance
Successful verification issues:
- Access token (JWT, HS512) — 15-minute expiry, claims
{ userId, organizationId, type: "patient-portal", role, organizationAccessRole }. - Refresh token — 32 random bytes encoded as base64url. Stored only as a SHA3-512 hash. Sliding expiry 30 days, absolute expiry 90 days.
The response sets Cache-Control: no-store.
Example Request
- cURL (Email)
- cURL (SMS)
- JavaScript
- Python
curl -X POST '<BASE_URL>/api/v1/users/auth/verify-otp' \
-H 'cv-api-key: <redacted>' \
-H 'Content-Type: application/json' \
-d '{
"email": "patient@example.com",
"code": "123456"
}'
curl -X POST '<BASE_URL>/api/v1/users/auth/verify-otp' \
-H 'cv-api-key: <redacted>' \
-H 'Content-Type: application/json' \
-d '{
"phoneNumber": "+15551234567",
"code": "123456"
}'
const response = await fetch(
'<BASE_URL>/api/v1/users/auth/verify-otp',
{
method: 'POST',
headers: {
'cv-api-key': '<redacted>',
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'patient@example.com',
code: '123456',
}),
}
);
const data = await response.json();
console.log(data);
import requests
response = requests.post(
'<BASE_URL>/api/v1/users/auth/verify-otp',
headers={
'cv-api-key': '<redacted>',
'Content-Type': 'application/json',
},
json={
'email': 'patient@example.com',
'code': '123456',
},
)
print(response.json())
Responses
▶200SuccessOTP accepted. Returns access + refresh tokens. Response includes Cache-Control: no-store.
{
"status": 200,
"success": true,
"accessToken": "<JWT, HS512>",
"expiresIn": 900,
"refreshToken": "<opaque-base64url, 32 random bytes>",
"refreshTokenExpiresAt": "2026-05-29T12:00:00.000Z",
"patientId": "550e8400-e29b-41d4-a716-446655440000"
}
▶400Validation errorcv-api-key missing, body fails Zod, or code is not 6 digits.
{
"status": 400,
"success": false,
"error": "Validation failed",
"code": "VALIDATION_ERROR"
}
▶404Organization not foundcv-api-key does not resolve to a partner organization.
{
"status": 404,
"success": false,
"error": "Organization not found",
"code": "NOT_FOUND"
}
▶401Invalid credentialsNo user matches the provided identifier in this organization.
{
"status": 401,
"success": false,
"error": "Invalid credentials",
"code": "VALIDATION_ERROR"
}
▶401Invalid or expired verification codeNo active OTP for the user, or attempts >= maxAttempts.
{
"status": 401,
"success": false,
"error": "Invalid or expired verification code",
"code": "VALIDATION_ERROR"
}
▶401Invalid verification codeCode hash did not match. The attempt was counted.
{
"status": 401,
"success": false,
"error": "Invalid verification code",
"code": "VALIDATION_ERROR"
}
▶401Verification code already usedCAS lost — another concurrent request already consumed the OTP.
{
"status": 401,
"success": false,
"error": "Verification code already used",
"code": "VALIDATION_ERROR"
}
All 401 responses use a deliberately small set of generic messages to prevent enumeration. Do not surface specific OTP-failure reasons to end users beyond what is returned.