Skip to content

Auth — /v1/auth

All auth endpoints live at /v1/auth/. None require an Authorization header unless marked requires session.


Create a new user account and organization.

Request

{
"org_name": "Acme AI",
"email": "[email protected]",
"password": "min-8-chars"
}

Response 201

{
"user": {
"user_id": "usr_abc123",
"email": "[email protected]",
"email_verified": false,
"org_id": "org_xyz789"
}
}

Errors

StatusErrorReason
400validation_errorMissing or invalid fields
409Email already registered

A verification email is sent automatically.


Exchange email + password for a signed JWT (valid 24h). If 2FA is enabled on the account, a challenge token is returned instead of a session token.

Request

{
"email": "[email protected]",
"password": "your-password"
}

Response 200 — 2FA disabled

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"user_id": "usr_abc123",
"email": "[email protected]",
"email_verified": true,
"org_id": "org_xyz789"
}
}

Response 200 — 2FA enabled

{
"requires_2fa": true,
"challenge_token": "ch_7f3kx9mq..."
}

When requires_2fa is true, submit the OTP to POST /v1/auth/verify-2fa to receive a session token.

Errors

StatusReason
400Validation error
401Invalid credentials

Exchange a 2FA challenge token + OTP for a session JWT. The challenge token is single-use and burned on first use (correct or incorrect). After 5 incorrect attempts, the challenge is permanently invalidated.

Request

{
"challenge_token": "ch_7f3kx9mq...",
"otp": "123456"
}

Response 200

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"user_id": "usr_abc123",
"email": "[email protected]",
"email_verified": true,
"org_id": "org_xyz789"
}
}

Errors

StatusReason
400Missing or invalid fields
401Wrong OTP — challenge token is now burned
429Too many failed attempts

Full 2FA flow

Terminal window
# Step 1: Login
curl -X POST https://api.certivu.ai/v1/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "password": "••••••••" }'
# → { "requires_2fa": true, "challenge_token": "ch_7f3kx9mq..." }
# Step 2: Submit OTP from authenticator app
curl -X POST https://api.certivu.ai/v1/auth/verify-2fa \
-H "Content-Type: application/json" \
-d '{ "challenge_token": "ch_7f3kx9mq...", "otp": "123456" }'
# → { "token": "eyJ...", "user": { ... } }

Requires session. Blocklists the current JWT so it cannot be reused, even if it has not yet expired. The client should also discard the token locally.

Response 200

{ "success": true }

Requires session. Returns the current user and their organization.

Response 200

{
"user": {
"user_id": "usr_abc123",
"email": "[email protected]",
"email_verified": true,
"org_id": "org_xyz789",
"created_at": "2026-05-01T12:00:00Z"
},
"org": {
"org_id": "org_xyz789",
"name": "Acme AI",
"plan": "starter"
}
}

Confirm an email address using the token from the verification email.

Request

{ "token": "<verification-token>" }

Response 200

{ "success": true }

Errors

StatusReason
400Token missing or malformed
400Token expired or already used

Re-send the email verification link.

Request

{ "email": "[email protected]" }

Response 200

{ "success": true }

Always returns 200 to prevent email enumeration.


Send a password reset link to the given email address.

Request

{ "email": "[email protected]" }

Response 200

{ "success": true }

Always returns 200 even if the email doesn’t exist (prevents enumeration). Reset links expire after 1 hour.


Set a new password using a valid reset token.

Request

{
"token": "<reset-token-from-email>",
"password": "new-password-min-8"
}

Response 200

{ "success": true }

Errors

StatusReason
400Validation error
400Token expired or already used

The JWT payload contains:

{
"user_id": "usr_abc123",
"email": "[email protected]",
"org_id": "org_xyz789",
"iat": 1748390400,
"exp": 1748476800
}

Algorithm: HS256. Expiry: 24 hours.

Pass the token as a Bearer header on session-protected endpoints:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

The same Authorization: Bearer header also accepts ctv_key_… API keys on all non-auth routes — the backend distinguishes by prefix.