Skip to content

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.

EnvironmentBase URL
Productionhttps://partizap.ru/api
Developmenthttps://dev.partizap.ru/api
Local devhttp://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

HTTPCodeWhen
401authentication_errorNot logged in or invalid credentials
403authorization_errorLogged in but not allowed
404not_found_errorResource doesn't exist
409conflict_errorDuplicate (e.g. email already registered)
422validation_errorInvalid input, details has per-field errors
429rate_limit_errorToo 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:

  1. Read CSRF_TOKEN cookie (readable, not HTTP-only)
  2. Send as X-CSRF-TOKEN header
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 ?? '',
  },
});

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',
});
  • 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: 100
  • X-RateLimit-Remaining: N
  • X-RateLimit-Reset: <unix timestamp>

Auth-specific:

EndpointLimitWindowPer
POST /auth/login515 minemail
POST /auth/register31 hourIP
POST /auth/forgot-password31 houremail
POST /auth/verify-email515 minuser

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:

  1. 8-128 characters
  2. At least 1 uppercase letter (A-Z)
  3. At least 1 lowercase letter (a-z)
  4. At least 1 digit (0-9)
  5. At least 1 special character (!@#$%^&* etc.)
  6. No 3+ consecutive identical characters (e.g. aaa)
  7. No 3+ sequential characters (e.g. abc, 321)
  8. Not a common password
  9. 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

MethodEndpointDescription
GET/api/store/productsList 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}/similarSimilar products by category/compatibility

GET /api/store/products query params:

  • sort: date_desc (default), price_asc, price_desc
  • make_id, model_id, generation_id, modification_id — YMMM filter
  • category_id, primary_category_id
  • city_id, region_id, district_id, metro_station_id — geo filter
  • city_ids — comma-separated city IDs (overrides region_id if both provided)
  • steering: left, right, both, universal
  • is_availabletrue/1 or false/0
  • condition — condition category ID (e.g. 96 = Б/У)
  • price_min, price_max
  • limit, 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

GET /api/store/products/search

Full-text search across product titles, descriptions, and OEM numbers.

Query params:

  • q (required) — search query string
  • city_ids — comma-separated city IDs (overrides region_id if both provided)
  • region_id — fallback geo filter when city_ids not provided
  • is_availabletrue/1 or false/0
  • condition — 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

MethodEndpointDescription
GET/api/store/sellers/{id}Seller profile + business profile
GET/api/store/sellers/{id}/productsSeller's active products

Cars (YMMM Cascade)

MethodEndpointDescriptionCache
GET/api/store/cars/makesAll makes (?popular=true for popular only)1h
GET/api/store/cars/makes/{id}/modelsModels for a make1h
GET/api/store/cars/models/{id}/generationsGenerations for a model1h
GET/api/store/cars/generations/{id}/modificationsModifications for a generation1h

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)

MethodEndpointDescriptionCache
GET/api/store/geo/regionsAll regions1h
GET/api/store/geo/regions/{id}/citiesCities in region1h
GET/api/store/geo/cities/{id}/districtsDistricts in city1h
GET/api/store/geo/cities/{id}/metroMetro stations in city1h

Metro fields include line, line_color, lat, lon.

Categories

MethodEndpointDescriptionCache
GET/api/store/categoriesAll active categories1h

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

MethodEndpointDescription
GET/api/vendor/meSeller profile + business profile
PUT/api/vendor/meUpdate profile (display_name, account_type, city_id, district_id, metro_station_id)
POST/api/vendor/me/avatarUpload 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

MethodEndpointDescription
GET/api/vendor/me/phonesList user's phones (sorted by sort_order)
POST/api/vendor/me/phonesAdd 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: true clears primary flag on other phones
  • sort_order is 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

MethodEndpointDescription
GET/api/vendor/productsSeller's products (all statuses, ?status= filter)
POST/api/vendor/productsCreate 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}/publishSubmit 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:

  • part categories (e.g. 22 = Амортизаторы) — at least one, set same ID as primary_category_id
  • condition category (e.g. 96 = Б/У) — exactly one
  • attribute category (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 / archived

Product Images

MethodEndpointDescription
POST/api/vendor/products/{id}/imagesUpload (multipart, max 10MB)
PUT/api/vendor/products/{id}/images/orderReorder: { "image_ids": [5,3,1] }
DELETE/api/vendor/products/{id}/images/{imgId}Delete image

Images are processed asynchronously via a Redis queue:

  1. Upload returns 201 with status: 'processing' — variant URLs (thumbnail_webp, medium_webp, etc.) are null
  2. A background worker generates image variants and sets status: 'ready' with populated URLs
  3. On failure: status: 'failed', error_message is populated

Frontend should check status before rendering image URLs. Display a placeholder or spinner while status === 'processing'.

Favorites

MethodEndpointDescription
GET/api/vendor/favoritesUser's favorites (with product data)
POST/api/vendor/favoritesAdd: { "product_id": 1 }
DELETE/api/vendor/favorites/{product_id}Remove

Sessions

MethodEndpointDescription
GET/api/vendor/sessionsList active sessions
DELETE/api/vendor/sessions/{id}Kill specific session
MethodEndpointDescription
GET/api/vendor/conversations/{id}/messages/searchSearch messages within a conversation
GET/api/vendor/messages/searchSearch 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 (q less than 2 characters)
  • 404 — Conversation not found (in-conversation search only)
  • 403 — Not a participant (in-conversation search only)

Enums

EnumValues
ProductStatusdraft, pending, active, sold, archived, rejected
Steeringleft, right, both, universal
AccountTypepersonal, business
CategoryTypepart, 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.