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 |
| 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 400INVALID_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 400INVALID_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