Быстрый старт
Ключ → GET /v1/me → GET /v1/stock → пагинация курсором. Дальше на этой же странице — соглашения API, глоссарий и частые вопросы: прочитайте один раз от начала до конца, и почти всё остальное в документации станет очевидным.
Шаг 1. Получить ключ
Для боевой интеграции ключ выпускает менеджер ПерфектПак в админке — юрлицу
целиком, а не одному кабинету маркетплейса: один ключ видит остатки,
приёмки, заказы и отгрузки по всем вашим кабинетам сразу, различая их полем
marketplace в каждой записи. Значение показывается ровно один
раз, в момент выпуска — мы храним только его хеш, восстановить нельзя,
только перевыпустить.
Чтобы попробовать API прямо сейчас, не дожидаясь менеджера, получите
тестовый ключ песочницы — те же вызовы, тот же формат ответов, но данные
вымышленного склада и префикс pp_test_ вместо pp_live_
(подробности — на странице «Песочница»).
curl -X POST https://api.perfektpak.ru/v1/sandbox/keysimport 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 /stock | 100 | 1000 |
GET /orders | 100 | 1000 |
GET /receipts | 50 | 200 |
GET /shipments | 50 | 200 |
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 — один запрос, четыре блока для
дашборда. Формулы те же, что использует личный кабинет селлера: если цифры
разошлись, это повод написать нам, а не «у вас так устроено».
funnel— принято → на хранении → отгружено → отсортировано маркетплейсом → доставлено.in_storage— снимок сейчас (принято минус отгружено минус списано), должен совпадать с суммойquantityпоGET /stock.storage—units/boxes/pallets/sku_countсейчас;received/shippedза период.receiving—fill_rate = accepted_units / expected_units;shortages_by_reason— расшифровка по причинам (см. гайд «Сверка недостач»).shipments—orders_by_status, сумма всегда равнаorders_total(набор статусов исчерпывающий по построению — ни один заказ не «теряется» между вкладками).
FAQ
Можно ли через API что-то изменить или создать?
С v1.1 — да, но ровно одно: заявка на приёмку, POST /v1/receipts
(см. гайд «Заявка на приёмку из 1С»). Всё остальное по-прежнему только
читается — отменить заказ, поменять отгрузку или остаток через API нельзя,
это по-прежнему делает оператор ПерфектПак через личный кабинет.
Что делать, если ключ утёк?
Написать менеджеру ПерфектПак — он отзовёт ключ немедленно, и все запросы
этим ключом сразу начнут получать 401 key_revoked. Дальше
попросите выпустить новый ключ для той же интеграции. Каждый вызов пишется
в журнал (время, метод, путь, IP) — при необходимости можно разобрать, что
успели прочитать по утёкшему ключу.
Почему 404, а не 403, если объект не мой?
Ответ — в разделе «Изоляция между селлерами» выше.
Почему в ответе бывает null, а не просто отсутствие поля?
Это два разных сообщения «данных нет». Поле со значением null
присутствует всегда, но иногда ему нечего показать (например, closed_at
у ещё не закрытой отгрузки). Поле, которого нет в ответе вовсе, —
необязательное и появляется, только когда есть что показать (например,
article). Разметка — в components/schemas файла
openapi.yaml.