Appearance
Frontend Developer Reference
Everything a Nuxt frontend developer needs to know about the Partizap backend API.
Base URL
All API requests go through the /api prefix. Nginx strips it before passing to PHP.
| Environment | Base URL |
|---|---|
| Production | https://partizap.ru/api |
| Development | https://dev.partizap.ru/api |
| Local dev | http://localhost:8000 (no /api prefix needed) |
In nuxt.config.ts:
ts
runtimeConfig: {
public: {
apiBase: '/api', // same-origin, nginx handles proxy
},
},Response Format
Success
json
{
"data": { ... },
"meta": { "has_more": true, "next_cursor": "..." }
}meta only present on paginated list endpoints.
Error
json
{
"error": {
"code": "validation_error",
"message": "Human-readable message",
"details": { "email": ["Email is required"] }
}
}details only present on validation errors (422).
Error Codes
| HTTP | Code | When |
|---|---|---|
| 401 | authentication_error | Not logged in or invalid credentials |
| 403 | authorization_error | Logged in but not allowed |
| 404 | not_found_error | Resource doesn't exist |
| 409 | conflict_error | Duplicate (e.g. email already registered) |
| 422 | validation_error | Invalid input, details has per-field errors |
| 429 | rate_limit_error | Too many requests, check Retry-After header |
Authentication
Session-based with HTTP-only cookies (not JWT). The browser handles cookies automatically.
CSRF Protection
Every mutating request (POST/PUT/PATCH/DELETE) must include CSRF token:
- Read
CSRF_TOKENcookie (readable, not HTTP-only) - Send as
X-CSRF-TOKENheader
ts
// composables/useApi.ts
const csrfToken = useCookie('CSRF_TOKEN');
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password },
credentials: 'include',
headers: {
'X-CSRF-TOKEN': csrfToken.value ?? '',
},
});SSR Cookie Forwarding
For SSR requests (server-side), forward browser cookies to the API:
ts
// In server routes or SSR composables
const headers = useRequestHeaders(['cookie']);
const data = await $fetch('/api/auth/me', {
headers,
credentials: 'include',
});Session Cookie
- Name:
PARTIZAP_SESSION - HTTP-only (JS cannot read it)
- Secure (HTTPS only in production)
- SameSite: Lax
- Managed automatically by the browser
Rate Limiting
Global: 100 requests/minute per IP.
Response headers on every request:
X-RateLimit-Limit: 100X-RateLimit-Remaining: NX-RateLimit-Reset: <unix timestamp>
Auth-specific:
| Endpoint | Limit | Window | Per |
|---|---|---|---|
POST /auth/login | 5 | 15 min | |
POST /auth/register | 3 | 1 hour | IP |
POST /auth/forgot-password | 3 | 1 hour | |
POST /auth/verify-email | 5 | 15 min | user |
On 429: Retry-After header contains seconds to wait.
Cursor Pagination
All list endpoints use cursor-based pagination (no offset/page numbers).
Query params:
limit(default: 20, max: 100)cursor(opaque string, pass from previous response)
Usage:
ts
// First page
const { data, meta } = await $fetch('/api/store/products?limit=20');
// Next page
if (meta.has_more) {
const page2 = await $fetch(`/api/store/products?limit=20&cursor=${meta.next_cursor}`);
}Auth Endpoints
POST /api/auth/register
json
// Request
{ "email": "user@example.com", "password": "SecureP@ss1", "display_name": "John Doe" }
// Response 201 — session cookie set automatically
{ "data": { "id": 1, "email": "...", "display_name": "...", "email_verified": false, ... } }Side effects: sends 6-digit email verification code (expires in 15 min).
POST /api/auth/login
json
// Request
{ "email": "user@example.com", "password": "SecureP@ss1", "remember": true }
// Response 200
{ "data": { "id": 1, "email": "...", "display_name": "...", ... } }remember (optional, boolean): if true, session lifetime extends to 30 days (default: session-only cookie).
Errors:
- 401
"Invalid email or password"— wrong credentials (intentionally vague) - 401
"Account temporarily locked. Try again later."— 10+ failed attempts, 30-min lockout - 401
"Account is deactivated"—is_active = false
Progressive delays: 2s after 4 attempts, 5s after 6, 10s after 8.
GET /api/auth/me
Returns current user if session is valid, 401 otherwise.
json
// Response 200
{ "data": { "id": 1, "email": "...", "display_name": "...", ... } }POST /api/auth/verify-email (authenticated)
json
// Request
{ "code": "123456" }
// Response 200
{ "data": { "verified": true } }POST /api/auth/resend-verification (authenticated)
json
// Response 200
{ "data": { "sent": true } }Errors: 422 if email already verified or last code sent <60s ago.
POST /api/auth/forgot-password
json
// Request
{ "email": "user@example.com" }
// Response 200 — always succeeds (prevents email enumeration)
{ "data": { "sent": true } }POST /api/auth/reset-password
json
// Request
{ "token": "<from email link>", "password": "NewSecureP@ss1" }
// Response 200
{ "data": { "reset": true } }Side effect: invalidates ALL user sessions.
POST /api/auth/logout (authenticated)
Destroys current session only.
POST /api/auth/logout-all (authenticated)
Destroys all sessions for the user.
Password Validation Rules
The backend enforces these rules — mirror them in the frontend for UX:
- 8-128 characters
- At least 1 uppercase letter (A-Z)
- At least 1 lowercase letter (a-z)
- At least 1 digit (0-9)
- At least 1 special character (!@#$%^&* etc.)
- No 3+ consecutive identical characters (e.g.
aaa) - No 3+ sequential characters (e.g.
abc,321) - Not a common password
- Doesn't contain the email local part
User Object
Private (returned to the user themselves via GET /auth/me and GET /vendor/me):
json
{
"id": 1,
"email": "user@example.com",
"display_name": "John Doe",
"account_type": "personal",
"email_verified": false,
"avatar_url": "https://s3.selectel.ru/...",
"city_id": 1,
"district_id": 5,
"metro_station_id": 3,
"rating": "0.00",
"reviews_count": 0,
"products_count": 0,
"is_active": true,
"is_admin": false,
"created_at": "2026-02-07T12:30:45+00:00",
"phones": [
{
"id": 1,
"user_id": 1,
"phone": "+79991234567",
"label": "Основной",
"is_primary": true,
"sort_order": 0,
"phone_verified": true,
"created_at": "2026-02-07T12:30:45+00:00"
}
]
}Public (returned when viewing another user/seller):
json
{
"id": 5,
"display_name": "BMW Parts Store",
"avatar_url": "https://s3.selectel.ru/...",
"city_id": 1,
"rating": "4.85",
"reviews_count": 127,
"products_count": 342,
"created_at": "2025-06-15T08:00:00+00:00"
}Store Endpoints (Public, No Auth)
Products
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/store/products | List active products (filtered, sorted, paginated) |
| GET | /api/store/products/search?q=... | Full-text search |
| GET | /api/store/products/{id} | Product detail with images, compatibility, seller |
| GET | /api/store/products/{id}/similar | Similar products by category/compatibility |
GET /api/store/products query params:
sort:date_desc(default),price_asc,price_descmake_id,model_id,generation_id,modification_id— YMMM filtercategory_id,primary_category_idcity_id,region_id,district_id,metro_station_id— geo filtercity_ids— comma-separated city IDs (overridesregion_idif both provided)steering:left,right,both,universalis_available—true/1orfalse/0condition— condition category ID (e.g. 96 = Б/У)price_min,price_maxlimit,cursor
Product Detail Response
Returned by GET /api/store/products/{id}, GET /api/vendor/products/{id}, GET /api/admin/products/{id}.
json
{
"data": {
"id": 1,
"seller_id": 5,
"title": "Бампер передний BMW 3 Series",
"description": "Оригинальный бампер BMW",
"price": 2500.00,
"steering": "left",
"oem_number": "51117140396",
"manufacturer": "BMW",
"city_id": 1,
"region_id": 47,
"primary_category_id": 50,
"district_id": 5,
"metro_station_id": 3,
"address": "Невский проспект, 40",
"phone_id": 1,
"is_available": true,
"status": "active",
"views_count": 42,
"favorites_count": 3,
"published_at": "2026-02-01T10:30:00+00:00",
"created_at": "2026-02-01T10:00:00+00:00",
"images": [
{
"id": 1,
"thumbnail_webp": "https://s3...",
"thumbnail_jpeg": "https://s3...",
"medium_webp": "https://s3...",
"medium_jpeg": "https://s3...",
"large_webp": "https://s3...",
"large_jpeg": "https://s3...",
"status": "ready",
"is_primary": true,
"sort_order": 0
}
],
"categories": [
{ "id": 1, "product_id": 1, "category_id": 50, "is_primary": true },
{ "id": 2, "product_id": 1, "category_id": 96, "is_primary": false },
{ "id": 3, "product_id": 1, "category_id": 99, "is_primary": false }
],
"compatibility": [
{ "id": 1, "product_id": 1, "make_id": 2, "model_id": 10, "generation_id": 45, "modification_id": 501, "note": null }
],
"oem_numbers": [
{ "id": 1, "product_id": 1, "oem_number": "51117140396", "oem_display": "51117140396" }
],
"seller": {
"id": 5,
"display_name": "BMW Parts Store",
"avatar_url": "...",
"rating": "4.85",
"reviews_count": 127,
"products_count": 342
}
}
}Similar Products
GET /api/store/products/{id}/similar
Returns similar active products based on shared category or car compatibility. No pagination — returns a flat list.
Response 200:
json
{
"data": [
{
"id": 3,
"title": "Тормозной диск передний",
"price": 3200,
"status": "active",
"images": [
{ "id": 2, "thumbnail_webp": "https://s3...", "is_primary": true, "sort_order": 0 }
]
}
]
}Errors:
- 404 — product not found or not active
Search
GET /api/store/products/search
Full-text search across product titles, descriptions, and OEM numbers.
Query params:
q(required) — search query stringcity_ids— comma-separated city IDs (overridesregion_idif both provided)region_id— fallback geo filter whencity_idsnot providedis_available—true/1orfalse/0condition— condition category ID (e.g. 96 = Б/У)limit,cursor
categories array: M:N from product_categories table. Each entry has category_id referencing a category (which has category_type: part, condition, or attribute) and is_primary flag. The primary_category_id on the product itself is a denormalized shortcut to the primary part category.
Sellers
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/store/sellers/{id} | Seller profile + business profile |
| GET | /api/store/sellers/{id}/products | Seller's active products |
Cars (YMMM Cascade)
| Method | Endpoint | Description | Cache |
|---|---|---|---|
| GET | /api/store/cars/makes | All makes (?popular=true for popular only) | 1h |
| GET | /api/store/cars/makes/{id}/models | Models for a make | 1h |
| GET | /api/store/cars/models/{id}/generations | Generations for a model | 1h |
| GET | /api/store/cars/generations/{id}/modifications | Modifications for a generation | 1h |
Generation fields: id, model_id, name, code, year_from, year_to, steering.
Modification fields: id, generation_id, name, engine_volume, fuel_type, power, transmission, drivetrain.
Geography (4-Level Cascade)
| Method | Endpoint | Description | Cache |
|---|---|---|---|
| GET | /api/store/geo/regions | All regions | 1h |
| GET | /api/store/geo/regions/{id}/cities | Cities in region | 1h |
| GET | /api/store/geo/cities/{id}/districts | Districts in city | 1h |
| GET | /api/store/geo/cities/{id}/metro | Metro stations in city | 1h |
Metro fields include line, line_color, lat, lon.
Categories
| Method | Endpoint | Description | Cache |
|---|---|---|---|
| GET | /api/store/categories | All active categories | 1h |
Category types: part, condition, attribute.
Part categories use a multi-level hierarchy (up to 4 levels) based on Avito structure:
Level 1: Запчасти, Аксессуары, Масла и автохимия, Шины/диски/колёса, ...
Level 2: Запчасти → Для автомобилей, Для мототехники, ...
Level 3: Для автомобилей → Двигатель, Кузов, Подвеска, Тормозная система, ...
Level 4: Двигатель → Блок цилиндров, Головка блока, Коленвал, ...Each category has parent_id pointing to its parent (NULL for top-level). Use parent_id to build the tree on the frontend.
Vendor Endpoints (Authenticated Sellers)
All require session cookie. Email must be verified for product creation.
Profile
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/me | Seller profile + business profile |
| PUT | /api/vendor/me | Update profile (display_name, account_type, city_id, district_id, metro_station_id) |
| POST | /api/vendor/me/avatar | Upload avatar (multipart, max 5MB, JPEG/PNG/WebP) |
PUT /api/vendor/me — Update profile:
json
{
"display_name": "Updated Name",
"account_type": "business",
"city_id": 1,
"district_id": 5,
"metro_station_id": 3
}All fields optional. account_type accepts personal or business. Phone numbers are managed separately via /vendor/me/phones.
Phone Management
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/me/phones | List user's phones (sorted by sort_order) |
| POST | /api/vendor/me/phones | Add phone (max 5 per user) |
| PUT | /api/vendor/me/phones/{id} | Update phone |
| DELETE | /api/vendor/me/phones/{id} | Delete phone |
POST /api/vendor/me/phones — Add phone:
json
// Request
{ "phone": "+79991234567", "label": "Рабочий", "is_primary": false }
// Response 201
{ "data": { "id": 2, "user_id": 1, "phone": "+79991234567", "label": "Рабочий", "is_primary": false, "sort_order": 1, "phone_verified": false, "created_at": "..." } }Required: phone (format: +7XXXXXXXXXX). Optional: label (max 50 chars, default "Основной"), is_primary (boolean).
Business rules:
- Max 5 phones per user
- First phone is always set as primary
- Setting
is_primary: trueclears primary flag on other phones sort_orderis auto-assigned (max + 1)
PUT /api/vendor/me/phones/{id} — Update phone:
json
{ "label": "Рабочий", "is_primary": true, "sort_order": 0 }All fields optional. Same validation as create for phone and label.
DELETE /api/vendor/me/phones/{id}:
Returns 204. If deleted phone was primary, the first remaining phone is promoted to primary. Products linked to the deleted phone have their phone_id set to NULL automatically (FK ON DELETE SET NULL).
Products
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/products | Seller's products (all statuses, ?status= filter) |
| POST | /api/vendor/products | Create product (draft) |
| GET | /api/vendor/products/{id} | Product detail (with categories, compatibility, images, oem_numbers) |
| PUT | /api/vendor/products/{id} | Update (draft/rejected only) |
| POST | /api/vendor/products/{id}/publish | Submit for approval (draft -> pending) |
| DELETE | /api/vendor/products/{id} | Delete (draft only) |
POST /api/vendor/products — Create product:
json
{
"title": "Амортизатор передний BMW E39",
"price": 4500.00,
"description": "Оригинальный амортизатор в хорошем состоянии",
"oem_numbers": ["31311096855", "31316785589"],
"manufacturer": "Sachs",
"steering": "left",
"region_id": 47,
"city_id": 1,
"district_id": 3,
"metro_station_id": 12,
"address": "ул. Ленина, 15",
"phone_id": 1,
"primary_category_id": 22,
"category_ids": [22, 96, 99],
"compatibility": [
{ "make_id": 1, "model_id": 10, "generation_id": 25, "modification_id": 42 }
]
}Required: title, price. All other fields optional.
phone_id — ID of a phone from /vendor/me/phones to display as contact for this product. Optional; if omitted, no phone is linked. On update, send "phone_id": null to explicitly clear.
category_ids — array of category IDs from all types:
partcategories (e.g. 22 = Амортизаторы) — at least one, set same ID asprimary_category_idconditioncategory (e.g. 96 = Б/У) — exactly oneattributecategory (e.g. 99 = Оригинал) — zero or one
compatibility — array of car fitment entries. Each requires make_id; model_id, generation_id, modification_id are optional.
oem_numbers — array of OEM number strings (e.g. ["04465-33471", "04465-06090"]). Populates the M:N oem_numbers/product_oem tables. The first value is synced to the denormalized oem_number field on the product. If oem_numbers is not provided, a single oem_number string field is still accepted as fallback.
PUT /api/vendor/products/{id} accepts the same fields (all optional — only sent fields are updated). category_ids, compatibility, and oem_numbers use replace-all strategy.
Product status flow:
draft -> (publish) -> pending -> (admin) -> active
-> rejected
active -> sold / archivedProduct Images
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/vendor/products/{id}/images | Upload (multipart, max 10MB) |
| PUT | /api/vendor/products/{id}/images/order | Reorder: { "image_ids": [5,3,1] } |
| DELETE | /api/vendor/products/{id}/images/{imgId} | Delete image |
Images are processed asynchronously via a Redis queue:
- Upload returns 201 with
status: 'processing'— variant URLs (thumbnail_webp,medium_webp, etc.) arenull - A background worker generates image variants and sets
status: 'ready'with populated URLs - On failure:
status: 'failed',error_messageis populated
Frontend should check status before rendering image URLs. Display a placeholder or spinner while status === 'processing'.
Favorites
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/favorites | User's favorites (with product data) |
| POST | /api/vendor/favorites | Add: { "product_id": 1 } |
| DELETE | /api/vendor/favorites/{product_id} | Remove |
Sessions
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/sessions | List active sessions |
| DELETE | /api/vendor/sessions/{id} | Kill specific session |
Message Search
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/conversations/{id}/messages/search | Search messages within a conversation |
| GET | /api/vendor/messages/search | Search messages across all user's conversations |
GET /api/vendor/conversations/{id}/messages/search — In-conversation search:
Query params: q (required, min 2 chars), limit (default 50, max 100), cursor (optional).
Returns messages matching the query (ILIKE, case-insensitive) within the specified conversation. User must be a participant.
json
// Response 200
{
"data": [
{
"id": 542,
"conversation_id": 12,
"sender_id": 5,
"type": "text",
"text": "Генератор в наличии, могу отправить фото",
"image_url": null,
"image_thumbnail": null,
"status": "read",
"created_at": "2026-02-20T14:30:00+03:00"
}
],
"meta": { "has_more": false, "next_cursor": null }
}GET /api/vendor/messages/search — Global search:
Query params: q (required, min 2 chars), limit (default 20, max 100), cursor (optional).
Searches across all conversations where the user is buyer or seller. Excludes soft-deleted conversations. Each result includes conversation context.
json
// Response 200
{
"data": [
{
"id": 542,
"conversation_id": 12,
"sender_id": 5,
"type": "text",
"text": "Генератор в наличии, могу отправить фото",
"image_url": null,
"image_thumbnail": null,
"status": "read",
"created_at": "2026-02-20T14:30:00+03:00",
"conversation": {
"id": 12,
"companion": { "id": 5, "name": "Иван" },
"product": { "id": 88, "title": "Генератор BMW E46" }
}
}
],
"meta": { "has_more": true, "next_cursor": "abc123" }
}Errors:
422— Query too short (qless than 2 characters)404— Conversation not found (in-conversation search only)403— Not a participant (in-conversation search only)
Enums
| Enum | Values |
|---|---|
| ProductStatus | draft, pending, active, sold, archived, rejected |
| Steering | left, right, both, universal |
| AccountType | personal, business |
| CategoryType | part, condition, attribute |
CORS
Allowed origins: partizap.ru, dev.partizap.ru, localhost:3000
Allowed headers: Content-Type, Authorization, X-CSRF-TOKEN, X-Request-ID
Credentials: true (cookies sent cross-origin)Content Type
All requests and responses use application/json, enforced by middleware. Exception: image uploads use multipart/form-data.