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 minIP
POST /auth/register31 hourIP
POST /auth/forgot-password31 hourIP
POST /auth/validate-reset-token515 minIP
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 "Too many failed attempts from this address. Try again later." — 10+ failed attempts from this IP for this email, 30-min lockout
  • 401 "Account is deactivated"is_active = false

Brute-force protection (per IP+email pair, stored in Redis):

  • Progressive delays: 2s after 4 attempts, 5s after 6, 10s after 8
  • Lockout after 10 failed attempts — blocks that IP from trying that email for 30 min
  • Other IPs are not affected — an attacker cannot lock out the real user
  • Successful login resets the counter for that IP+email pair
  • Alert email is sent to the user after 15 failed attempts across all IPs

GET /api/auth/me

Returns current user if session is valid, 401 otherwise. Response includes phones array and business_profile (if the user has one). Same shape as GET /vendor/me.

json
// Response 200
{
  "data": {
    "id": 1,
    "email": "user@example.com",
    "display_name": "John Doe",
    "account_type": "business",
    "email_verified": true,
    "phones": [ ... ],
    "business_profile": {
      "id": 1,
      "company_name": "BMW Parts Store",
      "inn": "7712345678",
      "address": "Невский проспект, 40",
      "website": "https://example.com",
      "working_hours": "10:00-18:00",
      "is_verified": false,
      "verified_at": null,
      "created_at": "2026-02-07T12:30:45+00:00"
    }
  }
}

business_profile is only present when the user has account_type: "business" and has created a profile. For personal accounts it is omitted.

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/validate-reset-token

Validates a password reset token without consuming it. Use this when the user opens the reset link — check the token before showing the new password form.

json
// Request
{ "token": "<from email link>" }

// Response 200 — token is valid
{ "data": { "valid": true } }

// Response 422 — token not found, expired, or already used
{ "error": { "code": "validation_error", "message": "Validation failed",
             "details": { "token": ["Invalid or expired reset token"] } } }

Rate-limited: 5 requests / 15 min per IP.

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, GET /vendor/me, PUT /admin/users/{id}):

json
{
  "id": 1,
  "email": "user@example.com",
  "display_name": "John Doe",
  "account_type": "business",
  "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"
    }
  ],
  "business_profile": {
    "id": 1,
    "company_name": "BMW Parts Store",
    "inn": "7712345678",
    "address": "Невский проспект, 40",
    "website": "https://example.com",
    "working_hours": "10:00-18:00",
    "is_verified": false,
    "verified_at": null,
    "created_at": "2026-02-07T12:30:45+00:00"
  }
}

business_profile is conditionally included only when the user has created one (typically account_type: "business"). Omitted otherwise.

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/search active products (filtered, sorted, paginated)
GET/api/store/products/suggest?q=...Lightweight search suggestions for dropdown (id, title, price, thumbnail)
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:

  • q — full-text search query (optional). When present, default sort is relevance
  • sort: date_desc (default without q), price_asc, price_desc, relevance (only with q)
  • make_id, model_id, generation_id, modification_id — YMMM filter
  • category_id — category filter (includes descendants via many-to-many)
  • city_ids — comma-separated city IDs (mutually exclusive with region_id — sending both returns 422)
  • region_id, district_id, metro_station_id — geo filter
  • steering: left, right, both, universal (validated against enum, invalid value returns 422)
  • is_availabletrue/1 or false/0
  • condition — condition category ID (e.g. 96 = Б/У)
  • price_min, price_max
  • limit, cursor

Validation rules:

  • sort=relevance without q → 422 "relevance sort requires q parameter"
  • city_ids + region_id both present → 422 "city_ids and region_id are mutually exclusive"
  • Invalid steering value → 422 "Invalid steering. Allowed: left, right, both, universal"

Response: all items include images array (may be empty)

Search Suggestions

GET /api/store/products/suggest

Lightweight FTS endpoint for search dropdown hints. Single SQL query, no Doctrine hydration, no search logging. Designed for keystroke-debounced autocomplete (~1-3ms server-side).

Query params:

  • q — search query (returns empty array if missing or blank)
  • limit — max results (default 6, max 10)

Response 200:

json
{
  "data": [
    { "id": 204, "title": "Пружины Lada Vesta", "price": 3500, "thumbnail": "https://cdn.partizap.ru/products/204/thumb.webp" },
    { "id": 55, "title": "Пружина задняя BMW E46", "price": 1800, "thumbnail": null }
  ]
}

thumbnail is the primary image's thumbnail_webp URL, or null if no images.

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

categories array (in detail response): 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)

Admin Endpoints (Requires Admin Role)

All admin endpoints require both AuthMiddleware (valid session) and AdminMiddleware (is_admin = true). Returns 401 if not authenticated, 403 if not admin.

Dashboard & Stats

MethodEndpointDescription
GET/api/admin/statsDashboard overview
GET/api/admin/stats/productsProduct statistics
GET/api/admin/stats/usersUser statistics

GET /api/admin/stats — Dashboard overview:

json
// Response 200
{
  "data": {
    "total_users": 150,
    "total_products": 1200,
    "products_by_status": [
      { "status": "active", "count": 800 },
      { "status": "pending", "count": 25 },
      { "status": "draft", "count": 100 }
    ],
    "new_users_today": 5,
    "new_products_today": 12
  }
}

GET /api/admin/stats/products — Product statistics:

json
// Response 200
{
  "data": {
    "by_status": [
      { "status": "active", "count": 800 },
      { "status": "pending", "count": 25 }
    ],
    "by_city": [
      { "city_name": "Санкт-Петербург", "count": 650 },
      { "city_name": "Выборг", "count": 80 }
    ],
    "recent_pending": 3
  }
}

recent_pending — count of products in pending status older than 24 hours (awaiting moderation).

GET /api/admin/stats/users — User statistics:

json
// Response 200
{
  "data": {
    "total": 150,
    "active": 142,
    "verified_email": 130,
    "by_account_type": [
      { "account_type": "personal", "count": 120 },
      { "account_type": "business", "count": 30 }
    ],
    "new_this_week": 12,
    "new_this_month": 45
  }
}

User Management

MethodEndpointDescription
GET/api/admin/usersList all users (paginated)
GET/api/admin/users/{id}User detail (with phones, business_profile)
PUT/api/admin/users/{id}Update user (is_active, is_admin)
DELETE/api/admin/users/{id}Soft-delete user (deactivate + archive products)

GET /api/admin/users — List users:

Query params: limit (default 20, max 100), after (cursor).

Returns paginated user list (base toArray() without phones/business_profile).

GET /api/admin/users/{id} — User detail:

Returns full private User Object (with phones array and business_profile if present). Same shape as GET /auth/me.

PUT /api/admin/users/{id} — Update user:

json
// Request — all fields optional
{ "is_active": false, "is_admin": true }

Returns full private User Object (with phones and business_profile).

Errors:

  • 400 "self_deactivation" — cannot set is_active: false on your own account
  • 404 — user not found

DELETE /api/admin/users/{id} — Soft-delete user:

Deactivates the user (is_active = false) and archives all their products (status = archived). Returns 204.

Errors:

  • 400 "self_deletion" — cannot delete your own account
  • 404 — user not found

Product Moderation

MethodEndpointDescription
GET/api/admin/productsList all products (paginated, optional ?status= filter)
GET/api/admin/products/pendingList pending products only (paginated)
GET/api/admin/products/{id}Product detail (with images, categories, compatibility, oem_numbers, seller)
PUT/api/admin/products/{id}/approveApprove pending product
PUT/api/admin/products/{id}/rejectReject pending product
DELETE/api/admin/products/{id}Delete product

GET /api/admin/products — List all products:

Query params: limit (default 20, max 100), after (cursor), status (optional filter: draft, pending, active, etc.).

Returns paginated product list (base toArray() without relations).

GET /api/admin/products/pending — List pending products:

Same as above but pre-filtered to status = pending.

GET /api/admin/products/{id} — Product detail:

Same shape as the store product detail (includes images, categories, compatibility, oem_numbers, seller). No status filter — returns any product regardless of status.

PUT /api/admin/products/{id}/approve — Approve product:

No request body. Sets status to active, sets published_at. Sends approval email to seller.

json
// Response 200
{ "data": { "id": 1, "status": "active", "published_at": "2026-03-01T...", ... } }

Errors:

  • 404 — product not found
  • 422 — product is not in pending status

PUT /api/admin/products/{id}/reject — Reject product:

json
// Request
{ "reason": "Некорректное описание товара" }

reason is required. Sets status to rejected, stores rejection reason. Sends rejection email to seller. Broadcasts system message to product conversations.

json
// Response 200
{ "data": { "id": 1, "status": "rejected", "rejection_reason": "Некорректное описание товара", ... } }

Errors:

  • 404 — product not found
  • 422 — product is not in pending status

DELETE /api/admin/products/{id} — Delete product:

Deletes the product within a DB transaction (delete + audit log). Broadcasts system message "Объявление удалено" to all active conversations about this product after the transaction succeeds. Returns 204.

Reference Data CRUD (Cars)

All car reference data endpoints follow the same pattern: Create returns 201, Update returns 200, Delete returns 204. All invalidate the relevant Redis cache.

Makes

MethodEndpointDescription
POST/api/admin/cars/makesCreate make
PUT/api/admin/cars/makes/{id}Update make
DELETE/api/admin/cars/makes/{id}Delete make

POST /api/admin/cars/makes:

json
// Request
{ "name": "BMW", "slug": "bmw", "logo_url": "https://...", "is_popular": true }

Required: name, slug. Optional: logo_url, is_popular.

Models

MethodEndpointDescription
POST/api/admin/cars/modelsCreate model
PUT/api/admin/cars/models/{id}Update model
DELETE/api/admin/cars/models/{id}Delete model

POST /api/admin/cars/models:

json
// Request
{ "make_id": 1, "name": "3 Series", "slug": "3-series" }

Required: make_id, name, slug. Parent make must exist.

PUT /api/admin/cars/models/{id}:

Optional: name, slug.

Generations

MethodEndpointDescription
POST/api/admin/cars/generationsCreate generation
PUT/api/admin/cars/generations/{id}Update generation
DELETE/api/admin/cars/generations/{id}Delete generation

POST /api/admin/cars/generations:

json
// Request
{ "model_id": 10, "name": "E46", "code": "E46", "year_from": 1998, "year_to": 2006, "steering": "left" }

Required: model_id, name. Optional: code, year_from, year_to, steering (must be valid Steering enum value).

PUT /api/admin/cars/generations/{id}:

Optional: name, code, year_from, year_to, steering.

Modifications

MethodEndpointDescription
POST/api/admin/cars/modificationsCreate modification
PUT/api/admin/cars/modifications/{id}Update modification
DELETE/api/admin/cars/modifications/{id}Delete modification

POST /api/admin/cars/modifications:

json
// Request
{
  "generation_id": 45,
  "name": "320i 2.0 AT",
  "engine_volume": 2.0,
  "fuel_type": "petrol",
  "power": 150,
  "transmission": "automatic",
  "drivetrain": "rwd"
}

Required: generation_id, name. Optional: engine_volume, fuel_type (FuelType enum), power, transmission (Transmission enum), drivetrain (Drivetrain enum). Invalid enum values return 422 with allowed values listed.

PUT /api/admin/cars/modifications/{id}:

Optional: name, engine_volume, fuel_type, power, transmission, drivetrain.

Reference Data CRUD (Categories)

MethodEndpointDescription
POST/api/admin/categoriesCreate category
PUT/api/admin/categories/{id}Update category
DELETE/api/admin/categories/{id}Delete category

POST /api/admin/categories:

json
// Request
{ "name": "Двигатель", "slug": "engine", "category_type": "part", "icon": "engine", "parent_id": 1, "sort_order": 10 }

Required: name, slug, category_type (CategoryType enum). Optional: icon, parent_id, sort_order.

PUT /api/admin/categories/{id}:

Optional: name, slug, category_type, icon, parent_id, sort_order, is_active.

Validates circular references when updating parent_id — a category cannot be its own parent or create a cycle in the hierarchy.

Reference Data CRUD (Geography)

Regions

MethodEndpointDescription
POST/api/admin/geo/regionsCreate region
PUT/api/admin/geo/regions/{id}Update region

POST /api/admin/geo/regions:

json
{ "name": "Ленинградская область", "slug": "leningradskaya-oblast", "lat": "59.9343", "lon": "30.3351" }

Required: name, slug. Optional: lat, lon.

PUT accepts same fields, all optional.

Cities

MethodEndpointDescription
POST/api/admin/geo/citiesCreate city
PUT/api/admin/geo/cities/{id}Update city

POST /api/admin/geo/cities:

json
{ "region_id": 47, "name": "Санкт-Петербург", "slug": "saint-petersburg", "lat": "59.9343", "lon": "30.3351" }

Required: region_id, name, slug. Optional: lat, lon.

PUT accepts name, slug, lat, lon — all optional.

Districts

MethodEndpointDescription
POST/api/admin/geo/districtsCreate district
PUT/api/admin/geo/districts/{id}Update district

POST /api/admin/geo/districts:

json
{ "city_id": 1, "name": "Адмиралтейский", "slug": "admiralteyskiy" }

Required: city_id, name, slug.

PUT accepts name, slug — all optional.

Metro Stations

MethodEndpointDescription
POST/api/admin/geo/metro-stationsCreate metro station
PUT/api/admin/geo/metro-stations/{id}Update metro station

POST /api/admin/geo/metro-stations:

json
{ "city_id": 1, "name": "Невский проспект", "line": "Линия 2", "line_color": "#0078BE", "lat": "59.9352", "lon": "30.3226" }

Required: city_id, name, line, line_color. Optional: lat, lon.

PUT accepts name, line, line_color, lat, lon — all optional.


Enums

EnumValues
ProductStatusdraft, pending, active, sold, archived, rejected
Steeringleft, right, both, universal
FuelTypepetrol, diesel, hybrid, electric, gas
Transmissionat, mt, cvt, amt, robot
Drivetrainfwd, rwd, awd, 4wd
AccountTypepersonal, business
CategoryTypepart, condition, attribute
MessageTypetext, image, system
ReportStatuspending, resolved, rejected
PaymentStatuspending, completed, failed, cancelled
PaymentTypepromotion, featured, subscription

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.