ПерфектПак · API v1.1
openapi.yaml ↗

Быстрый старт

Ключ → GET /v1/meGET /v1/stock → пагинация курсором. Дальше на этой же странице — соглашения API, глоссарий и частые вопросы: прочитайте один раз от начала до конца, и почти всё остальное в документации станет очевидным.

Шаг 1. Получить ключ

Для боевой интеграции ключ выпускает менеджер ПерфектПак в админке — юрлицу целиком, а не одному кабинету маркетплейса: один ключ видит остатки, приёмки, заказы и отгрузки по всем вашим кабинетам сразу, различая их полем marketplace в каждой записи. Значение показывается ровно один раз, в момент выпуска — мы храним только его хеш, восстановить нельзя, только перевыпустить.

Чтобы попробовать API прямо сейчас, не дожидаясь менеджера, получите тестовый ключ песочницы — те же вызовы, тот же формат ответов, но данные вымышленного склада и префикс pp_test_ вместо pp_live_ (подробности — на странице «Песочница»).

curl -X POST https://api.perfektpak.ru/v1/sandbox/keys
import requests

resp = requests.post("https://api.perfektpak.ru/v1/sandbox/keys")
resp.raise_for_status()
key = resp.json()["key"]
print(key)  # pp_test_...
<?php
$ch = curl_init("https://api.perfektpak.ru/v1/sandbox/keys");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = json_decode(curl_exec($ch), true);
$key = $response["key"]; // pp_test_...
Соединение = Новый HTTPСоединение("api.perfektpak.ru", 443, , , , , Новый ЗащищенноеСоединениеOpenSSL);
Запрос = Новый HTTPЗапрос("/v1/sandbox/keys");
Ответ = Соединение.ОтправитьДляОбработки(Запрос, "POST"); // ОтправитьДляОбработки не поддерживает POST без тела в старых версиях — используйте HTTPМетод("POST") при необходимости
Тело = ПрочитатьJSON(Ответ.ПолучитьТелоКакСтроку());
Ключ = Тело.key; // pp_test_...

Ответ — 201 { "key": "pp_test_…", "expires_after_idle": "24h" }. Ключ живёт, пока к нему обращаются; после 24 часов без запросов песочница сбрасывается.

Шаг 2. Кто я по этому ключу — GET /v1/me

Первый вызов для любой интеграции: какому юрлицу принадлежит ключ, какие кабинеты маркетплейсов подключены, на каком складе лежит товар.

curl https://api.perfektpak.ru/v1/me \
  -H "Authorization: Bearer $PP_KEY"
import requests

resp = requests.get(
    "https://api.perfektpak.ru/v1/me",
    headers={"Authorization": f"Bearer {PP_KEY}"},
)
resp.raise_for_status()
me = resp.json()
print(me["client"]["name"], me["warehouse"]["name"])
<?php
$ch = curl_init("https://api.perfektpak.ru/v1/me");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $ppKey"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$me = json_decode(curl_exec($ch), true);
echo $me["client"]["name"];
Заголовки = Новый Соответствие;
Заголовки.Вставить("Authorization", "Bearer " + КлючPP);
Запрос = Новый HTTPЗапрос("/v1/me", Заголовки);
Ответ = Соединение.ОтправитьДляОбработки(Запрос);
Данные = ПрочитатьJSON(Ответ.ПолучитьТелоКакСтроку());
Сообщить(Данные.client.name);
{
  "client": { "id": "f2b1a7d0-8c3e-4b1a-9f2d-6a7c1e4d5b90", "name": "ООО «Северная Чашка»", "inn": "7712345678" },
  "cabinets": [
    { "id": "b4b6b0a1-df3f-4a63-8e11-2a6f6b6e2b10", "marketplace": "WB", "name": "Северная Чашка (WB)", "active": true, "synced_at": "2026-08-29T14:50:00+03:00" }
  ],
  "warehouse": { "code": "SPB1", "name": "Шушары" },
  "key": { "id": "k_9f3a1c2b7e", "name": "1С", "preview": "pp_live_9f3K…4mQ2", "created_at": "2026-06-01T10:00:00+03:00", "last_used_at": "2026-08-29T14:50:00+03:00" }
}
Ошибки на этом шаге

401 unauthorized — заголовок не передан или ключ не существует; 401 key_revoked — ключ отозван. Полный список — на странице «Ошибки».

Шаг 3. Остатки — GET /v1/stock

Самая часто опрашиваемая ручка API. Без фильтров возвращает все SKU клиента — у типового селлера их 11–106, обычно одна страница.

curl "https://api.perfektpak.ru/v1/stock?limit=100" \
  -H "Authorization: Bearer $PP_KEY"
resp = requests.get(
    "https://api.perfektpak.ru/v1/stock",
    params={"limit": 100},
    headers={"Authorization": f"Bearer {PP_KEY}"},
)
page = resp.json()
for item in page["items"]:
    print(item["barcode"], item["quantity"], item["available"])
<?php
$ch = curl_init("https://api.perfektpak.ru/v1/stock?limit=100");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $ppKey"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$page = json_decode(curl_exec($ch), true);
foreach ($page["items"] as $item) {
    echo $item["barcode"] . ": " . $item["available"] . "\n";
}
Запрос = Новый HTTPЗапрос("/v1/stock?limit=100", Заголовки);
Ответ = Соединение.ОтправитьДляОбработки(Запрос);
Страница = ПрочитатьJSON(Ответ.ПолучитьТелоКакСтроку());
Для Каждого Позиция Из Страница.items Цикл
    Сообщить(Позиция.barcode + ": " + Позиция.available);
КонецЦикла;
{
  "items": [
    { "barcode": "4680012340015", "article": "COFFEE-1KG-BRIZ", "name": "Кофе в зёрнах «Утренний Бриз» 1 кг",
      "quantity": 560, "available": 512, "in_question": 48, "reserved": 120, "boxes": 14, "pallets": 1,
      "marketplace_stock": [ { "marketplace": "WB", "quantity": 430, "synced_at": "2026-08-29T14:47:00+03:00" } ],
      "updated_at": "2026-08-29T13:05:00+03:00" }
  ],
  "total": 37,
  "next_cursor": "MjAyNi0wOC0yOFQxODoyMjowMCswMzowMHw0NjgwMDEyMzQwMDIy"
}

Закладывайте в продажу available, а не quantity: разница — единицы «под вопросом» (in_question), уже учтённые в quantity. Держите значения свежими без лишнего трафика через ETag/If-None-Match:

curl "https://api.perfektpak.ru/v1/stock" \
  -H "Authorization: Bearer $PP_KEY" \
  -H 'If-None-Match: "b1946ac92492d2347c6235b4d2611184"' -i
# без изменений — HTTP/1.1 304 Not Modified, тело пустое

Шаг 4. Пагинация курсором

Списочные ручки отдают конверт {"items", "total", "next_cursor"}. next_cursor — непрозрачный токен для параметра cursor следующего запроса, и null, если страница последняя. Собирать курсор самостоятельно нельзя — только передавать то значение, что вернул API.

curl "https://api.perfektpak.ru/v1/orders?created_from=2026-08-01&created_to=2026-08-29&cursor=MjAyNi0wOC0yOVQwNzo0MDowMCswMzowMHxvXzVmNmE3YjhjOWY" \
  -H "Authorization: Bearer $PP_KEY"
cursor = None
while True:
    params = {"created_from": "2026-08-01", "created_to": "2026-08-29", "limit": 1000}
    if cursor:
        params["cursor"] = cursor
    page = requests.get(
        "https://api.perfektpak.ru/v1/orders", params=params,
        headers={"Authorization": f"Bearer {PP_KEY}"},
    ).json()
    handle(page["items"])
    cursor = page["next_cursor"]
    if not cursor:
        break
<?php
$cursor = null;
do {
    $url = "https://api.perfektpak.ru/v1/orders?created_from=2026-08-01&created_to=2026-08-29&limit=1000";
    if ($cursor) $url .= "&cursor=" . urlencode($cursor);
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $ppKey"]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $page = json_decode(curl_exec($ch), true);
    handle($page["items"]);
    $cursor = $page["next_cursor"];
} while ($cursor !== null);
Курсор = Неопределено;
Пока Истина Цикл
    Путь = "/v1/orders?created_from=2026-08-01&created_to=2026-08-29&limit=1000";
    Если Курсор <> Неопределено Тогда
        Путь = Путь + "&cursor=" + КодироватьСтроку(Курсор, СпособКодированияСтроки.КодировкаURL);
    КонецЕсли;
    Ответ = Соединение.ОтправитьДляОбработки(Новый HTTPЗапрос(Путь, Заголовки));
    Страница = ПрочитатьJSON(Ответ.ПолучитьТелоКакСтроку());
    ОбработатьЗаказы(Страница.items);
    Курсор = Страница.next_cursor;
    Если Курсор = Неопределено Тогда
        Прервать;
    КонецЕсли;
КонецЦикла;
Ошибки на этом шаге

400 bad_cursor — курсор не наш или испорчен: запросите первую страницу заново без параметра cursor, не пытайтесь его исправить. У GET /v1/orders обязательно задавайте created_from/created_to — объём растёт быстро (до ~900 заказов в сутки у крупного селлера), выгрузка без окна по датам одним запросом не задумана.

Соглашения

Время

Все времена в ответах — RFC 3339 со смещением +03:00 (Москва), например 2026-08-29T14:53:00+03:00. Ни UTC без смещения, ни «голых» дат в ответах не бывает. В параметрах запроса (created_from, created_to, from, to, planned_date) кроме полного RFC 3339 также принимается короткая дата ГГГГ-ММ-ДД — она читается как полночь по Москве. Знак + в смещении часового пояса внутри query-строки URL нужно кодировать как %2B.

Пагинация

РесурсПо умолчаниюМаксимум
GET /stock1001000
GET /orders1001000
GET /receipts50200
GET /shipments50200

limit вне диапазона молча округляется до ближайшей границы; ошибка — только если значение вообще не целое число.

Ошибки

Единый конверт: {"error": {"code", "message", "request_id"}}. code — машинный и стабильный, ветвитесь по нему; message — по-русски и может меняться без анонса. Полная таблица кодов — на странице «Ошибки».

Лимиты запросов

60 запросов в минуту на ключ в среднем, всплеск до 20 подряд. При превышении — 429 rate_limited с заголовком Retry-After. На каждом ответе /v1/*, кроме 401, — заголовки X-RateLimit-Limit (ёмкость всплеска, 20 — не средний лимит), X-RateLimit-Remaining, X-RateLimit-Reset (unix-время полного восстановления всплеска).

Свежесть данных маркетплейса

У каждого поля из кабинета маркетплейса рядом лежит synced_at — когда мы его последний раз оттуда забрали. Наши складские числа — реальное время сканов, у них updated_at. Эти два источника мы намеренно не сверяем автоматически (см. «Остатки в свою систему» в гайдах).

Изоляция между селлерами

Ключ видит только объекты вашего юрлица. Запрос чужого объекта отвечает так же, как запрос несуществующего — 404 not_found, не 403: API не подтверждает даже факт существования чужих данных. Если бы коды различались, по разнице ответов можно было бы перебором выяснять, какие идентификаторы существуют у других селлеров.

Глоссарий

У WB «поставка» — то, что вы (через нас) отправляете в Wildberries. У нас на складе «поставкой» исторически называют то, что вы привозите нам. Чтобы не разбирать на живых данных, кто что имел в виду, ресурсы API названы однозначно:

Ресурс APIЧто физически происходитКак называют в разговоре / в ЛК
receiptВы привезли товар к нам на склад (или завели заявку через API) — мы посчитали и приняли.«Поставка» (в раздел «Поставки») или «Приёмка»
shipmentМы собрали ваши заказы и передали их маркетплейсу.«Поставка WB» / «Отгрузка»
orderЗаказ покупателя по FBS.«Заказ» / «Сборочное задание WB»
stockФизический остаток у нас, по сканам каждой единицы.«Товары и остатки» → «на складе сейчас»
marketplace_stockОстаток, который показывает кабинет самого маркетплейса — мы его только зеркалим.«Остаток WB»

Сквозные номера. external_id заказа и отгрузки — тот же номер, что в кабинете WB или Ozon. «Под вопросом» (in_question) — единицы, которые физически на складе, но их количество или принадлежность требует разбора (например, расхождение при приёмке); уже включены в quantity, вычитать повторно не нужно — именно на эту величину available меньше quantity.

Метрики: формулы простыми словами

GET /v1/metrics?from&to — один запрос, четыре блока для дашборда. Формулы те же, что использует личный кабинет селлера: если цифры разошлись, это повод написать нам, а не «у вас так устроено».

FAQ

Можно ли через API что-то изменить или создать?

С v1.1 — да, но ровно одно: заявка на приёмку, POST /v1/receipts (см. гайд «Заявка на приёмку из 1С»). Всё остальное по-прежнему только читается — отменить заказ, поменять отгрузку или остаток через API нельзя, это по-прежнему делает оператор ПерфектПак через личный кабинет.

Что делать, если ключ утёк?

Написать менеджеру ПерфектПак — он отзовёт ключ немедленно, и все запросы этим ключом сразу начнут получать 401 key_revoked. Дальше попросите выпустить новый ключ для той же интеграции. Каждый вызов пишется в журнал (время, метод, путь, IP) — при необходимости можно разобрать, что успели прочитать по утёкшему ключу.

Почему 404, а не 403, если объект не мой?

Ответ — в разделе «Изоляция между селлерами» выше.

Почему в ответе бывает null, а не просто отсутствие поля?

Это два разных сообщения «данных нет». Поле со значением null присутствует всегда, но иногда ему нечего показать (например, closed_at у ещё не закрытой отгрузки). Поле, которого нет в ответе вовсе, — необязательное и появляется, только когда есть что показать (например, article). Разметка — в components/schemas файла openapi.yaml.