Как я вытащил из Ozon Seller API все скрытые списания за месяц - разбор кода на Python

Как я вытащил из Ozon Seller API все скрытые списания за месяц - разбор кода на Python

Проблема

В июне мой знакомый селлер показал отчёт по своему магазину на Ozon. Оборот за месяц - 187 тысяч. Чистыми на руки - 62. На вопрос "куда ушли остальные 125" кабинет Ozon отвечает обобщённо: "услуги логистики и рекламы", строчкой на 90 тысяч.

Дальше - вручную. Открываешь пять разных отчётов: детализация, оспаривания, отчёт по кампании, услуги, антифрод. Сводишь в Excel. На средний магазин это 3-5 часов работы в конце месяца. У большинства селлеров, с которыми я успел поговорить, эту работу никто не делает. Просто списывают разницу на "озон такой".

Меня зацепило другое: то, что "озон такой" - это не константа. Внутри этой строчки на 90 тысяч сидит несколько типов операций, каждый из которых можно отследить и часть - прямо в моменте отключить. Автоактивированные акции. Страхование FBS. Антифрод-удержания за подозрение на самовыкупы. Штрафы за отмены. У части из них есть срок оспаривания 7 дней, и если пропустишь - деньги ушли навсегда.

Я взял Ozon Seller API и написал скрипт на Python, который тянет весь финансовый поток, категоризирует каждую операцию и в конце недели присылает в Telegram отчёт: сколько ушло на реальные утечки, а сколько на нормальные комиссии. Ниже - разбор как это сделано и на что смотреть при повторении.

Что даёт Ozon Seller API

Сначала база - на случай если кто-то читает и раньше не подключался к API Ozon.

Ozon предоставляет два API: Seller API (управление товарами, заказами, финансы) и Performance API (реклама). Нам нужен первый.

Ключ получается в личном кабинете селлера: seller.ozon.ru/app/settings/api-keys. Тип ключа - Admin read only или любой другой с правами на раздел "Финансы". Это ключ только на чтение, никакие данные им изменить нельзя. Проверка прав - там же в кабинете.

Для работы с API нужны два значения: Client-Id (число) и Api-Key (строка типа abcd-1234-efgh-...). Оба передаются в HTTP-заголовках каждого запроса.

Базовый URL всех методов: https://api-seller.ozon.ru.

Про грядущее отключение метода 6 июля 2026

Важный момент, если вы читаете это после 6 июля. До 6 июля 2026 все финансовые операции магазина можно тянуть одним методом - POST /v3/finance/transaction/list. Он возвращает список операций с полем operation_type, по которому дальше их категоризируют.

С 6 июля 2026 Ozon отключает /v3/finance/transaction/list и /v3/finance/transaction/totals.

Официальная новость: dev.ozon.ru/news/699 - их заменяют новые методы:

  • POST /v1/finance/decompensation - удержания (штрафы, антифрод, промо, страхование). По сути ровно тот срез, который нам и нужен.

Новые методы структурно чище: удержания и начисления разделены на стороне Ozon, а accrual_id даёт готовый тип. Категоризатор становится проще.

В своём коде я оставил обе ветки и вынес переключение в конфиг - до 6 июля работает старый метод, после - новый.

Кусок 1: запрос финансовых операций

Скелет клиента для старого метода. Использую aiohttp вместо requests, потому что дальше это всё завёрнуто в async-бота.

import aiohttp import asyncio from datetime import datetime, timedelta OZON_BASE_URL = "https://api-seller.ozon.ru" class OzonClient: def __init__(self, client_id: str, api_key: str): self._headers = { "Client-Id": client_id, "Api-Key": api_key, "Content-Type": "application/json", } async def fetch_transactions(self, date_from: datetime, date_to: datetime): """Тянем все операции за период через пагинацию.""" all_ops = [] page = 1 while True: payload = { "filter": { "date": { "from": date_from.strftime("%Y-%m-%dT00:00:00.000Z"), "to": date_to.strftime("%Y-%m-%dT23:59:59.999Z"), }, "operation_type": [], "posting_number": "", "transaction_type": "all", }, "page": page, "page_size": 1000, } async with aiohttp.ClientSession() as session: async with session.post( f"{OZON_BASE_URL}/v3/finance/transaction/list", headers=self._headers, json=payload, ) as resp: data = await resp.json() result = data.get("result", {}) ops = result.get("operations", []) all_ops.extend(ops) if page >= result.get("page_count", 1) or not ops: break page += 1 await asyncio.sleep(0.3) return all_ops

Пара важных нюансов, которые не сразу очевидны:

Про даты. Формат 2026-07-03T00:00:00.000Z обязателен именно с миллисекундами и суффиксом Z. Без миллисекунд - 400. Без Z - 400. Я час потратил, пока понял.

Про пагинацию. page_size максимум 1000. Если у магазина за месяц больше 1000 операций (у большинства так и есть на FBS), нужно ходить по страницам. Между запросами держу sleep(0.3) - не потому что Ozon явно тротлит, а на всякий случай, чтобы не словить 429.

Про operation_type в фильтре. Пустой массив = все типы операций. Если поставить конкретный, например ["OperationMarketplaceServiceItemDelivery"], вернутся только они. Полный список типов - в документации Ozon, их пара десятков.

Кусок 2: категоризация

Тут самое интересное. Ozon возвращает operation_type (техническое имя типа MarketplaceServiceItemAntifraud) и operation_type_name (человекочитаемое имя типа "Антифрод-удержание"). Оба поля используются для правил.

Категории, которые я в итоге вывел, глядя на реальные операции:

  • sale - продажи и возвраты покупателю (не утечка)
  • delivery - логистика, магистраль, обработка (норма для FBS/FBO)
  • penalty - штрафы Ozon
  • antifraud - антифрод-удержания
  • promo - акции и скидки за счёт продавца
  • ads - реклама (Трафареты, поиск, брендовая полка)
  • insurance - страхование FBS
  • hidden_service - прочие платные сервисы Ozon, автоактивированные
  • other - не распознано, глазами

Утечкой считаю всё, кроме sale и delivery. Логистика - это нормальная себестоимость.

Правила категоризации - простой список пар "подстрока -> категория":

CATEGORY_RULES = [ ("penalty", "penalty"), ("штраф", "penalty"), ("antifraud", "antifraud"), ("антифрод", "antifraud"), ("promo", "promo"), ("акци", "promo"), ("marketing_action", "promo"), ("advertisement", "ads"), ("реклам", "ads"), ("insurance", "insurance"), ("страхован", "insurance"), ("delivery", "delivery"), ("logistic", "delivery"), ("fulfillment", "delivery"), ] def categorize(op_type: str, op_type_name: str) -> str: haystack = f"{op_type} {op_type_name}".lower() for needle, cat in CATEGORY_RULES: if needle.lower() in haystack: return cat if "service" in haystack or "сервис" in haystack: return "hidden_service" return "other"

Ход простой: по каждой операции проходим по правилам, первое совпадение выигрывает. Порядок правил в списке важен - более специфичные правила выше.

Что учесть при повторении:

1. Ozon использует и латиницу и кириллицу. Одна и та же операция в старых магазинах может прийти с англоязычным operation_type_name, в новых - с русским. Правила пишу для обоих вариантов.

2. Порядок правил. Правило "delivery" должно быть НИЖЕ правил "antifraud", потому что в некоторых типах антифрода в имени встречается слово delivery (тип возврата).

3. hidden_service как ловушка. Если операция содержит слово "сервис" и не подошла ни к одной другой категории - это скорее всего платный сервис Ozon, и его стоит показать селлеру отдельно с пометкой "разобраться вручную".

Кусок 3: построение отчёта

Дальше просто. Пробегаемся по всем операциям, суммируем по категориям, отбираем топ-5 самых крупных единичных списаний.

from collections import defaultdict LEAK_CATEGORIES = {"penalty", "antifraud", "promo", "insurance", "hidden_service"} def build_report(operations): by_category = defaultdict(float) leak_operations = [] for op in operations: amount = float(op.get("amount", 0) or 0) if amount >= 0: continue category = categorize( op.get("operation_type", ""), op.get("operation_type_name", ""), ) by_category[category] += amount if category in LEAK_CATEGORIES: leak_operations.append({ "date": op.get("operation_date"), "name": op.get("operation_type_name"), "amount": amount, "cat": category, }) total_leak = sum(v for k, v in by_category.items() if k in LEAK_CATEGORIES) top_5 = sorted(leak_operations, key=lambda x: x["amount"])[:5] return {"total_leak": total_leak, "by_category": dict(by_category), "top": top_5}

LEAK_CATEGORIES - множество тех категорий, которые считаем утечками: {"penalty", "antifraud", "promo", "insurance", "hidden_service"}.

Что получилось

Я собрал это в Telegram-бот с APScheduler. Раз в 6 часов ходит в Ozon, кэширует свежие операции в SQLite. Раз в неделю (понедельник, 10:00 МСК) шлёт селлеру сводку.

Ответ бота выглядит так:

Как я вытащил из Ozon Seller API все скрытые списания за месяц - разбор кода на Python

Пример разбивки на реальный магазин на FBS:

Как я вытащил из Ozon Seller API все скрытые списания за месяц - разбор кода на Python

Итого "утечка" - 53 800 из 72 000 общих списаний. Логистика в 18 450 ₽ вынесена отдельно, это нормальная себестоимость и её отключать нельзя.

Средняя картина по рынку

Ради интереса я поднял открытые данные и статьи по штрафам и удержаниям Ozon за первое полугодие 2026 (SelSup, A3-Agency, Uniseller). Средние доли по типам "утечек" от оборота:

  • Штрафы: 3-8%
  • Антифрод: 2-5%
  • Автоакции и промо: 5-15%
  • Страхование FBS: 1-3%

Суммарно у среднего селлера 5-7% оборота уходит на автоактивированные сервисы, о которых он не помнит. У некоторых больше. На обороте в 500 тысяч в месяц это 25-35 тысяч, которые можно вернуть себе без вложений - просто отключив то, что включено по умолчанию.

Про новый API после 6 июля

Как я писал выше - старый метод отключат. Новые методы делят финансовый поток иначе:

POST /v1/finance/decompensation { "date_from": "2026-06-26", "date_to": "2026-07-03", "page": 1, "page_size": 1000 } POST /v1/finance/accrual/by-day { "date_from": "2026-06-26", "date_to": "2026-07-03" }

Ключевое отличие: удержания больше не смешаны с продажами и логистикой в одной ленте. Отдельный метод под удержания. Отдельный метод под начисления, и у каждого начисления есть accrual_id, который прямо содержит тип операции (штраф, промо, страхование и т.д.). Категоризация становится не по regex, а по прямому ID.

Кому важна поддержка перехода - переписывайте до конца недели. Судя по обсуждениям в чатах разработчиков, у половины интеграций после 6 июля отвалятся отчёты, потому что все привыкли к transaction/list за три года.

Итог

Скрипт делает простую вещь: тянет то, что уже лежит в Ozon Seller API, но разложено по-другому. Никакой магии. Никакого парсинга сайта, никаких обходов лимитов, никаких серых схем.

Ценность именно в разложении: увидеть в цифрах, что "озон съел 90 тысяч" - это на самом деле 23 акции + 12 антифрод + 8 страхование + 4 штраф + 5 реклама + 18 логистики. Часть - норма. Часть - можно вернуть.

Я собрал это как pet-project под рабочим названием "Утечкометр". Сейчас в закрытой бете - беру 3-5 селлеров на бесплатный прогон, чтобы откалибровать правила категоризатора на живых данных из разных магазинов. Если интересно - пишите в личку t.me/vanya_base, пришлю ссылку на бота.

Если делаете своё - часть кода выше можно брать за основу, там ничего секретного. Единственное, что советую держать в голове: operation_type_name в API Ozon иногда меняется без объявления, поэтому категоризатор нужно периодически калибровать по свежим ответам.

Пишите в комменты, если у вас в отчёте всплывают типы операций, которых нет в моих правилах - интересно посмотреть.

P.S. Часть слов в тексте vc-редактор превратил в пустые ссылки - техническое, никуда не ведут, читайте как обычный текст.

Первые 5 обратившихся - бесплатный прогон за 24 часа.

Возьму 5 селлеров с оборотом от 100 тысяч в месяц. Подключаете API-ключ Ozon с правами только на чтение (Admin read only), в течение 24 часов присылаю персональный разбор с точными цифрами куда ушли деньги и что можно отключить.

Бесплатно, без обязательств. Взамен - обратная связь, была ли информация полезной.
Пишите в лс: t.me/vanya_base
Попробовать бота в закрытой бете: https://t.me/utechkometr_bot
UPD от 5 июля: Собрал в бот кнопку "Оспорить операцию" - при клике LLM генерирует готовый текст претензии с ID операции, суммой и юридическим обоснованием по оферте Ozon.
Скринкаст:

Утечкометр - бот

Автор: Иван Мирасов. Пишу серверный Python, сейчас занимаюсь автоматизациями для маркетплейс-селлеров.

6