Base URL: /api/v1. All requests and responses are JSON — send Accept: application/json.
Response envelope
Every response uses one envelope:
{
"status": "ok",
"message": "Success",
"data": {},
"errors": null,
"meta": null
}
statusis the string"ok"(HTTP < 400) or"error"(HTTP >= 400) — not the HTTP code.messageis localized (see Localization below).errorsandmetaarenullwhen empty and an object when filled — they never arrive as[], so the type never flips.datais the exception: it stays[]/{}when empty, so an empty list is still iterable.- Validation failures (422) put field errors under
errors:
{
"status": "error",
"message": "The given data was invalid",
"data": [],
"errors": { "email": ["The email field is required."] },
"meta": null
}
Authentication
Stateless Bearer tokens (Laravel Sanctum). Tokens expire after 5 days.
Public registration was removed: POST /api/v1/register no longer exists.
Accounts are created by an admin instead (planned for a later phase).
| Endpoint | Auth | Notes |
|---|---|---|
POST /api/v1/login |
— | email, password; returns user + token; throttled 6/min |
POST /api/v1/logout |
Bearer | revokes the current token |
GET /api/v1/user |
Bearer | current profile |
PUT /api/v1/user |
Bearer | updates name, email and password — see the policy below |
Send the token as Authorization: Bearer <token>. Inactive accounts are rejected at login exactly like bad credentials (401, no account enumeration). A token issued with an explicit future expires_at (machine/service tokens) outlives the global 5-day TTL; revoking it still takes effect immediately.
Every login attempt through this endpoint is written to the audit log, the same as an admin cookie login.
Password policy
Every password the API accepts is validated against Password::defaults(), configured once in AppServiceProvider::boot() and applied wherever a password is set (currently PUT /api/v1/user):
| Rule | Requirement |
|---|---|
| Length | at least 12 characters |
| Case | at least one uppercase and one lowercase letter |
| Digits | at least one number |
| Symbols | at least one symbol |
| Breach check | rejected if the password appears in the Have I Been Pwned corpus (k-anonymity: only a 5-character hash prefix leaves the server, never the password) |
A password failing any of these returns 422 with the reasons under errors.password. The breach check is a network call — if HIBP cannot be reached, Laravel skips that single rule and the other four still apply.
Rate limits
Two buckets, both per minute, both reported via X-RateLimit-Limit / X-RateLimit-Remaining:
| Traffic | Key | Default |
|---|---|---|
| Anonymous (no user, no bearer token) | client IP | 60/min |
| App (authenticated or bearer-carrying) | user id, else IP | 600/min |
POST /api/v1/login carries an extra route limit of 6/min. Exceeding any bucket returns 429.
Localization
Send Accept-Language: uz, ru or uz_cyrl — the response message values follow it and the response carries Content-Language. Unsupported locales fall back to uz. The Cyrillic tag is accepted in any casing (uz_cyrl, uz-cyrl, uz-Cyrl, UZ-CYRL) and always answers as uz_cyrl.
Errors
Every error response under /api/* uses the envelope above as JSON — this
holds regardless of whether the client sends an Accept: application/json
header.
| HTTP | When |
|---|---|
| 401 | Missing/invalid/expired token |
| 403 | Authenticated but not allowed |
| 404 | Unknown route or model |
| 422 | Validation failed (errors filled) |
| 429 | Rate limit exceeded |
| 500 | Server error (message is generic in production) |