Skip to content

Email and password changes

Changing the email address or the password on an account takes several steps. TEBHOS opens a ticket, emails a verification code, and every later step sends that ticket together with the codes and proofs collected so far. An account whose address the mail provider rejected uses bounced email recovery instead.

All routes on this page are user-only. They require a user session token (Authorization: Bearer <token>). A valid Bot <token> credential is refused with 403 ACCESS_DENIED.

The ordinary email change and the password change live under /auth. Bounced email recovery lives under /users/@me/email-change/bounced. These are the routes the TEBHOS web client uses.

Partial /users/@me aliases

TEBHOS also answers POST /users/@me/email-change/start, /users/@me/email-change/resend-original, /users/@me/email-change/resend-new, /users/@me/email-change/verify-new and /users/@me/password-change/resend. These routes keep their tickets in a separate store from the /auth routes. There is no /users/@me route to verify the original address, request a new address, apply an email token, or start, verify, or complete a password change. A ticket opened on one family cannot be finished on the other, so do not mix them. Use the /auth routes documented below.

Ticket and code contract

A ticket identifier is a version 4 UUID. TEBHOS issues it when the flow starts, and every later step sends it back as a plain string. A ticket belongs to the account that opened it. A ticket from another account, an unknown ticket, or an expired one fails with 400 BAD_REQUEST. Starting a flow again issues a fresh ticket and leaves an earlier one usable until it expires.

A verification code is eight characters from the uppercase Latin alphabet and the decimal digits, written as two four-character groups separated by a hyphen, for example 7KQ2-M9XD. Each code lives for 10 minutes from the moment it is sent, and every resend replaces the previous code. TEBHOS trims leading and trailing whitespace from a submitted code and compares the rest exactly.

A lowercase code never matches

A client that accepts free-form input uppercases the value and keeps the hyphen before it submits the code.

A proof is a version 4 UUID that TEBHOS returns from every step that establishes a fact about the ticket. The email change flow issues original_proof for the original address and requires it on Request new email and Complete email change. The password change flow issues verification_proof for the emailed code and requires it on Complete password change. A proof lives 10 minutes. Verifying the same code again issues a new proof and invalidates the previous one.

TEBHOS refuses a resend for 30 seconds after the previous send of the same code. The refusal is HTTP 429 RATE_LIMITED with a Retry-After header counting the seconds until the next send is allowed. This cooldown is separate from the route buckets and the email send controls below.

Tickets, codes and proofs are credentials

Each of these values can advance a security-sensitive account change. Keep them out of logs, analytics, URLs, and messages to unintended recipients. They are opaque, so preserve them exactly and derive nothing from their contents.

Errors

TEBHOS reports failures on these flows with general-purpose codes. The message field is a human-readable Turkish sentence, so branch on the HTTP status and code, never on the message text.

Status code Condition
400 BAD_REQUEST The ticket is unknown, expired, already completed, or belongs to another account; the code or proof does not match; the code was never sent
400 INVALID_FORM_BODY A field fails validation, for example a malformed address or a password outside 8-256 characters
403 FORBIDDEN A bounced recovery route was called by an account that is not marked bounced
409 CONFLICT The requested address already belongs to an account
429 RATE_LIMITED A route bucket, an email send control, or the 30-second resend cooldown refused the request

Not supported on TEBHOS

There are no granular codes such as INVALID_VERIFICATION_CODE, VERIFICATION_CODE_EXPIRED, TICKET_ALREADY_COMPLETED or EMAIL_ALREADY_IN_USE, and no errors array with per-field paths on these routes.

Email send controls

Each operation that sends an email counts against its own route bucket and, separately, against a 15-minute email send control for the account. Exhausting either produces HTTP 429 even when the other has room.

Control Allowance Operations
Original address 3 sends per 15 minutes Start email change (only when a code is sent) and Resend original email code
New address 5 sends per 15 minutes Request new email and Resend new email code
Password change start 3 sends per 15 minutes Start password change
Password change resend 3 sends per 15 minutes Resend password change code
Bounced recovery 5 sends per 15 minutes Request replacement email for bounced address and Resend replacement email code

Email change flow

The email change takes up to four steps.

Step Operation Result
1 Start email change A ticket, and a code sent to the current address when that address is verified
2 Verify original email The original_proof. Skipped when the start response already carries one
3 Request new email A code sent to the new address
4 Complete email change The new address is written to the account

Original address verification applies only when the account holds a verified email address. For any other account, Start email change returns the original_proof directly, and the client skips step 2.

Email change start object

The state of a newly created email change ticket.

Structure

Field Type Description
ticket string The identifier every later step of this flow sends
require_original boolean Whether the original address needs verification before a new address can be requested. True only when the account holds a verified email address
original_email ?string The address on the account, or null when the account has none
original_proof ?string The proof for the original address stage. Set exactly when require_original is false, otherwise null
original_code_expires_at ?ISO8601 timestamp The moment the code sent to the original address expires, 10 minutes after it was sent. null when require_original is false
resend_available_at ?ISO8601 timestamp The earliest moment the original address code can be resent, 30 seconds after the send. null when require_original is false

Example

{
  "ticket": "6f1a4c9e-3d52-4a77-9b0e-2c8f5d41ab30",
  "require_original": true,
  "original_email": "[email protected]",
  "original_proof": null,
  "original_code_expires_at": "2026-03-04T18:10:00+00:00",
  "resend_available_at": "2026-03-04T18:00:30+00:00"
}

New email request object

The state of a ticket with a new address bound to it and a code sent to that address.

Structure

Field Type Description
ticket string The ticket the request named. On Request replacement email for bounced address it is the ticket that operation created
new_email string The address now bound to the ticket
new_code_expires_at ISO8601 timestamp The moment the code sent to the new address expires, 10 minutes after it was sent
resend_available_at ISO8601 timestamp The earliest moment the code can be resent, 30 seconds after the send

Original email verification object

Structure

Field Type Description
original_proof string The proof Request new email and Complete email change require

Email change completion object

Structure

Field Type Description
message string A human-readable confirmation
email string The address now stored on the account

Password change start object

The state of a newly created password change ticket.

Structure

Field Type Description
ticket string The identifier every later step of this flow sends
code_expires_at ISO8601 timestamp The moment the emailed code expires, 10 minutes after it was sent
resend_available_at ISO8601 timestamp The earliest moment the code can be resent, 30 seconds after the send

Password verification object

Structure

Field Type Description
verification_proof string The proof Complete password change consumes

Password change completion object

The replacement session that Complete password change returns after the password is written.

Structure

Field Type Description
token string The session token for the replacement session. The token the request authenticated with stops working, so send every later request with this value
auth_session_id_hash string The hex-encoded hash of the replacement session

Start email change

POST /auth/email-change/start

Creates an email change ticket. Returns an email change start object on success.

When the account holds a verified email address, TEBHOS creates the ticket in the pending-original stage and sends one verification code to that address. Otherwise it creates the ticket with original_proof already issued and sends nothing.

JSON body

Send an empty object ({}). TEBHOS reads no field.

Response

Status Body Condition
200 email change start object The ticket was created

Rate limit

When a code is sent, 10 requests per minute for each authenticated user on the user:email_change:start bucket, plus the original address control. A ticket that skips the original stage is not rate limited.

Resend original email code

POST /auth/email-change/resend-original

Sends a fresh verification code to the account's current address for a ticket that is still in the pending-original stage. Returns 204 with an empty body.

A ticket that skipped the original stage has no original code, so this operation fails with 400 BAD_REQUEST on it.

JSON body

Field Type Description
ticket string The identifier returned by Start email change

Response

Status Body Condition
204 empty A replacement code was sent and the previous code stopped working

Rate limit

10 requests per minute for each authenticated user on the user:email_change:resend_original bucket, plus the original address control and the 30-second cooldown.

Verify original email

POST /auth/email-change/verify-original

Checks the code sent to the current address. Returns an original email verification object on success.

The code stays valid until it expires, so verifying it again succeeds and issues a new proof that replaces the previous one.

JSON body

Field Type Description
ticket string The identifier returned by Start email change
code string The code sent to the current address

Response

Status Body Condition
200 original email verification object The original address is verified
400 error response The ticket or the code was rejected

Rate limit

20 requests per minute for each authenticated user on the user:email_change:verify_original bucket.

Request new email

POST /auth/email-change/request-new

Binds a new address to the ticket and sends it a verification code. Returns a new email request object on success.

TEBHOS trims the address and converts it to lowercase, then checks it in order:

  • An address without @, shorter than 6 characters, or longer than 254 characters fails with 400 INVALID_FORM_BODY.
  • An address that any account already holds, including the caller's own current address, fails with 409 CONFLICT.

The request does not reserve the address. Ownership is checked again by Complete email change. Calling this operation again with a different address replaces the pending address on the same ticket, subject to the 30-second cooldown.

JSON body

Field Type Description
ticket string The identifier returned by Start email change
original_proof string The proof from Start email change or Verify original email
new_email string The address to bind to the ticket (6-254 characters)

Response

Status Body Condition
200 new email request object The address was bound and a code was sent
400 error response The proof was rejected or the address is malformed
409 error response The address is already in use

Rate limit

10 requests per minute for each authenticated user on the user:email_change:request_new bucket, plus the new address control and the 30-second cooldown.

Resend new email code

POST /auth/email-change/resend-new

Sends a fresh verification code to the address already bound to the ticket. Returns 204 with an empty body.

A ticket with no requested address fails with 400 BAD_REQUEST.

JSON body

Field Type Description
ticket string The identifier returned by Start email change

Response

Status Body Condition
204 empty A replacement code was sent and the previous code stopped working

Rate limit

10 requests per minute for each authenticated user on the user:email_change:resend_new bucket, plus the new address control and the 30-second cooldown.

Complete email change

POST /auth/email-change/complete

Checks the code sent to the new address and writes that address to the account in the same call. Returns an email change completion object on success. Emits a User Update Gateway event to the caller's sessions.

Sudo mode is not required. The two emailed codes are the proof of control over both addresses.

TEBHOS checks ownership of the new address again, so an address another account took since Request new email fails with 409 CONFLICT.

JSON body

Field Type Description
ticket string The identifier returned by Start email change
original_proof string The proof from Start email change or Verify original email
code string The code sent to the new address

Response

Status Body Condition
200 email change completion object The new address was written
400 error response The ticket, proof, or code was rejected
409 error response The address is already in use

Side effects

The account email is replaced and the previous address is released for other accounts. The email verified state is left as it was. TEBHOS records the change in the account's contact change log and deletes the ticket, so a second call fails with 400 BAD_REQUEST.

Nothing on this path revokes a credential, so sessions and OAuth2 tokens stay valid. No notice is sent to the previous address.

Rate limit

20 requests per minute for each authenticated user on the user:email_change:apply bucket.

Example

curl -X POST https://tebhos.com/api/v1/auth/email-change/complete \
  -H "Authorization: Bearer $TEBHOS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "ticket": "6f1a4c9e-3d52-4a77-9b0e-2c8f5d41ab30",
    "original_proof": "0b7e2c41-58a9-4d0f-9c3e-7a1d64f2e5b8",
    "code": "7KQ2-M9XD"
  }'

Bounced email recovery

An account whose stored address the mail provider rejected has email_bounced set on the user object. The operations below bind a replacement address and write it without the original address stage.

Every one of them requires the bounced marker. An account without it is rejected with 403 FORBIDDEN, and an account with no address at all fails with 400 INVALID_FORM_BODY.

The flow also requires that the stored address is not marked verified. When it is, Request replacement email for bounced address fails with 400 BAD_REQUEST, and that account changes its address through the ordinary email change flow instead.

The codes and the 30-second cooldown follow the ticket and code contract.

Request replacement email for bounced address

POST /users/@me/email-change/bounced/request-new

Creates a recovery ticket and binds the replacement address in the same call. Returns a new email request object on success.

TEBHOS trims the address and checks it in order:

  • An empty address fails with 400 INVALID_FORM_BODY.
  • An address equal to the bounced one, ignoring case, fails with 400 INVALID_FORM_BODY.
  • An address without @, shorter than 6 characters, or longer than 254 characters fails with 400 INVALID_FORM_BODY.
  • An address another account holds fails with 409 CONFLICT.

A failed attempt can still send mail

When the stored address is still marked verified, TEBHOS creates the ticket and sends one code to the stored address, counting it against the original address control, and only then fails the request with 400 BAD_REQUEST.

JSON body

Field Type Description
new_email string The address to replace the bounced one (6-254 characters)

Response

Status Body Condition
200 new email request object The replacement address was bound and a code was sent
403 error response The account is not marked bounced
409 error response The address is already in use

Keep the returned ticket, because the remaining steps take no other identifier.

Rate limit

10 requests per minute for each authenticated user on the user:email_change:bounced:request_new bucket, plus the bounced recovery control.

Resend replacement email code

POST /users/@me/email-change/bounced/resend-new

Sends a fresh verification code to the replacement address bound to a recovery ticket. Returns 204 with an empty body.

JSON body

Field Type Description
ticket string The identifier returned by Request replacement email for bounced address

Response

Status Body Condition
204 empty A replacement code was sent and the previous code stopped working
403 error response The account is not marked bounced

Rate limit

10 requests per minute for each authenticated user on the user:email_change:bounced:resend_new bucket, plus the bounced recovery control and the 30-second cooldown.

Verify replacement email for bounced address

POST /users/@me/email-change/bounced/verify-new

Checks the code and writes the replacement address to the account in the same call. Returns the updated user object on success. Emits a User Update Gateway event.

No sudo verification applies.

JSON body

Field Type Description
ticket string The identifier returned by Request replacement email for bounced address
code string The code sent to the replacement address

Response

Status Body Condition
200 user object The replacement address was verified and written
400 error response The ticket or the code was rejected, or the code expired
403 error response The account is not marked bounced

Side effects

The account gets the replacement address marked verified, email_bounced becomes false, and the suspicious-activity requirements that a verified email satisfies are cleared from required_actions. The ticket is completed and cannot be reused. The change is recorded in the contact change log. No notice is sent to the bounced address.

Rate limit

20 requests per minute for each authenticated user on the user:email_change:bounced:verify_new bucket.

Start password change

POST /auth/password-change/start

Creates a password change ticket and sends a verification code to the account's email address. Returns a password change start object on success.

Note

The code can only arrive at a stored address. TEBHOS does not refuse the request for an account without an email address, but such an account never receives a code.

JSON body

Send an empty object ({}). TEBHOS reads no field.

Response

Status Body Condition
200 password change start object The ticket was created and a code was sent

Rate limit

10 requests per minute for each authenticated user on the user:password_change:start bucket, plus the password change start control.

Resend password change code

POST /auth/password-change/resend

Sends a fresh verification code for an active password change ticket. Returns 204 with an empty body.

JSON body

Field Type Description
ticket string The identifier returned by Start password change

Response

Status Body Condition
204 empty A replacement code was sent and the previous code stopped working
400 error response The ticket is unknown, expired, or belongs to another account

Rate limit

10 requests per minute for each authenticated user on the user:password_change:resend bucket, plus the password change resend control and the 30-second cooldown.

Verify password change code

POST /auth/password-change/verify

Checks the emailed code. Returns a password verification object on success. The account password is still unchanged.

JSON body

Field Type Description
ticket string The identifier returned by Start password change
code string The code sent to the account address

Response

Status Body Condition
200 password verification object The code was accepted
400 error response The ticket or the code was rejected

Rate limit

20 requests per minute for each authenticated user on the user:password_change:verify bucket.

Complete password change

POST /auth/password-change/complete

Replaces the account password and the caller's session. Returns a password change completion object on success.

No sudo verification applies. The emailed code, proven by verification_proof, is the authorisation.

JSON body

Field Type Description
ticket string The identifier returned by Start password change
verification_proof string The proof from Verify password change code
new_password string The new password (8-256 characters)

Response

Status Body Condition
200 password change completion object The password was replaced
400 error response The proof was rejected (BAD_REQUEST) or the password length is out of range (INVALID_FORM_BODY)

Every other session is signed out

Completing a password change deletes every other session on the account and replaces the session that made the request. The token the request used stops working immediately. Switch to the returned token before sending anything else.

Side effects

The password is changed and the ticket, its proof, and its cooldown are deleted. TEBHOS sends an Auth Session Change Gateway event to the user so other open clients notice the sign-out. OAuth2 access tokens and refresh tokens stay valid.

Rate limit

10 requests per minute for each authenticated user on the user:password_change:complete bucket.

Example

const base = "https://tebhos.com/api/v1";
const headers = {
  Authorization: `Bearer ${token}`,
  "Content-Type": "application/json",
};

const start = await fetch(`${base}/auth/password-change/start`, {
  method: "POST", headers, body: "{}",
}).then((r) => r.json());

// The user reads the code from their inbox.
const { verification_proof } = await fetch(`${base}/auth/password-change/verify`, {
  method: "POST", headers,
  body: JSON.stringify({ ticket: start.ticket, code: codeFromEmail.trim().toUpperCase() }),
}).then((r) => r.json());

const done = await fetch(`${base}/auth/password-change/complete`, {
  method: "POST", headers,
  body: JSON.stringify({ ticket: start.ticket, verification_proof, new_password: newPassword }),
}).then((r) => r.json());

token = done.token; // the old token no longer works
import requests

base = "https://tebhos.com/api/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {token}"

start = s.post(f"{base}/auth/password-change/start", json={}).json()

# The user reads the code from their inbox.
proof = s.post(
    f"{base}/auth/password-change/verify",
    json={"ticket": start["ticket"], "code": code_from_email.strip().upper()},
).json()["verification_proof"]

done = s.post(
    f"{base}/auth/password-change/complete",
    json={"ticket": start["ticket"], "verification_proof": proof, "new_password": new_password},
).json()

token = done["token"]  # the old token no longer works