Ozon выключил финансовый API, а я две недели считал прибыль на мёртвых данных

8 сентября 2026 года Ozon отключил метод /v3/finance/transaction/list — единственный способ узнать, сколько денег по каждому отправлению реально доедет до продавца. Отключил тихо: без письма, без даты в changelog, просто в один день метод стал отвечать HTTP 400 {"code": 9, "message": "obsolete method cannot be used"}.
Мой синк это заметил сразу. Я — через две недели.
Это история про то, как устроены финансы в Seller API Ozon, чем заменять выключенный метод, где в новом API яма на 1,2 % и почему мониторинг «синк прошёл успешно» ничего не значит.
Зачем вообще лезть в финансовый API
Продавцу на маркетплейсе доступны три уровня правды о деньгах.
Первый — методы заказов (/v3/posting/fbs/list и родня). Там есть цена товара и «оплачено покупателем». Это деньги покупателя, а не ваши.
Второй — отчёт о реализации. Приходит раз в месяц, одной суммой, через две недели после конца периода. Для бухгалтерии годится, для управления — нет.
Третий — финансовые операции. Каждое начисление и удержание отдельной строкой с привязкой к номеру отправления: начислено за товар, комиссия, логистика до ПВЗ, последняя миля, обратная логистика при невыкупе, приём платежа. Только здесь можно ответить на вопрос «этот конкретный заказ — плюс или минус».
Разница между первым и третьим уровнем по моему кабинету за 14 дней: покупатели заплатили 1 616 172 ₽, до счёта дойдёт 1 289 065 ₽. Это 79,8 %, и распределено неравномерно: у одних товаров остаётся 70 %, у других — под 40 %.
Пример одного заказа из боевой базы, чтобы было понятно, о чём речь:
Начислено за товар 3 400,00
Комиссия площадки −1 632,00
Логистика −103,00
Обработка отправления −10,00
Последняя миля −11,69
──────────────────────────────────
Итого продавцу 1 643,31
Меньше половины. Обычная категория, покупатель забрал с первого раза.
Как это работало до 8 сентября
/v3/finance/transaction/list принимал период и отдавал операции постранично, по 1000 штук. Каждая операция — плоский объект: operation_id, operation_type, operation_date, posting.posting_number, accruals_for_sale, sale_commission, delivery_charge, return_delivery_charge, amount и массив services с ценами.
Две вещи, которые я выучил ещё на старом API и которые пригодились при переезде.
amount — источник истины, не пересчитывайте его. Соблазн большой: сложить accruals_for_sale, sale_commission и услуги, получить ту же цифру. Не получите. У штрафов, страхования, компенсаций нет ни начислений, ни услуг — только amount. На выборке в 400 операций так расходятся 35, и все они этого вида.
Операции без posting_number — общие расходы кабинета. Хранение, реклама, сбор отзывов. Их можно размазать по заказам пропорционально выручке, и многие сервисы аналитики так и делают. Я не размазываю: цифра по заказу должна сходиться с тем, что покажет кабинет по этому отправлению, а не быть «расчётной». Иначе при любом споре с площадкой вы проиграете собственной же таблице.
Как я две недели не замечал
Синк работает сторожем: раз в несколько минут обходит подключённые кабинеты, тянет заказы, возвраты и финансы. Каждый шаг пишет результат в sync_runs. Финансовый шаг с 8 сентября падал с obsolete method — на момент, когда я это увидел, в таблице лежало три с лишним тысячи одинаковых ошибок.
Почему не увидел раньше? Потому что мониторинг смотрел на синк целиком, а он был зелёный: заказы приезжали, возвраты приезжали, интерфейс обновлялся. Чистый доход по новым заказам просто не появлялся — а заказ без чистого дохода выглядит как заказ, по которому Ozon ещё не провёл финансовые операции. Это нормальное состояние на первые дни после доставки.
Через две недели «нормальное состояние» стало подозрительным.
Вывод, который стоило сделать давно: у каждого шага синка должна быть своя метрика «возраст последних данных». Не «прошёл ли шаг», а «когда последний раз что-то реально приехало». Для финансов сейчас это max(operation_date) по кабинету, и если он старше трёх дней при наличии доставленных заказов — тревога.
Чем заменять
Замена — /v1/finance/accrual/by-day. По смыслу тот же набор операций, но с тремя отличиями в интерфейсе.
Запрос строго на один день. Тело {"date": "2026-09-15"}. Попытка передать период отвечает value length must be 10 runes. Окно обходится по суткам.
Пагинация курсором, не страницами. В ответе last_id, его же кладёте в следующий запрос. Пустая строка — страниц больше нет.
Структура начисления вложенная, а не плоская. Появилось поле accrued_category с тремя значениями: POSTING — начисление по отправлению (внутри posting.products[] с delivery.services и commission), ITEM — сборы по товару (сюда попадает приём платежа), NON_ITEM — всё остальное.
Соответствие полей, которое я подтвердил на данных, а не по документации:
accrual_id -> operation_id
date -> operation_date
unit_number -> posting_number (у POSTING, ITEM и NON_ITEM одинаково)
total_amount.amount -> amount
Самое важное: Ozon сохранил прежние идентификаторы. accrual_id в новом API равен operation_id в старом. Это значит, что upsert по (seller_account_id, platform, operation_id) остаётся идемпотентным, историю не надо переписывать, а старые и новые данные лежат в одной таблице без шва.
Проверял на трёх днях, по которым были данные из старого API: 20.08, 01.09, 05.09. Совпало всё — число операций (175/175, 273/273, 324/324), суммы, привязка к отправлениям, сами id. Расхождение 0,00.
Яма на 1,2 %
Рядом лежит соблазнительный метод /v1/finance/accrual/postings — лукап по номерам отправлений, до 200 штук за запрос. Казалось бы, идеально: не надо обходить дни, спросил по нужным заказам и всё.
Не годится. Он отдаёт только POSTING-начисления. Приём платежа (эквайринг) лежит в категории ITEM и в этот метод не попадает. Сумма по заказу выходит меньше примерно на 1,2 % — мало, чтобы заметить на глаз, достаточно, чтобы не сходиться с кабинетом. Плюс выручка там лежит отдельным полем seller_price, а не в total_amount, и складывать надо руками.
Для чистого дохода нужен только by-day: там total_amount уже нетто — цена продавца минус комиссия минус логистика, и ITEM-начисления приходят тем же потоком.
Тот же заказ на 790 ₽ из нового API:
POSTING начислено 790,00, комиссия −379,20, логистика -> 306,50
ITEM приём платежа -> −7,02
──────────────────────────────────────────────────────────────────
299,48 (37,9 %)
Без ITEM было бы 306,50, и я бы никогда не понял, откуда семь рублей разницы с отчётом.
Услуги лежат где попало
В старом API услуги были массивом services[{name, price}] на верхнем уровне. В новом они разбросаны: у отправления — posting.products[].delivery.services, у товара — item_fees.fees[].fees[] (да, два уровня fees), у прочего — non_item_fee и container_fees. Названий нет, только type_id; расшифровка — отдельным справочником /v1/finance/accrual/types, он статичный, берётся раз и кешируется.
Угадывать форму на каждый случай я не стал. Рекурсия по дереву, которая собирает любую пару {type_id, accrued} на любой глубине:
def _walk_fees(node, out):
if isinstance(node, dict):
if "type_id" in node and isinstance(node.get("accrued"), dict):
out.append((node["type_id"], _amt(node["accrued"])))
return
for v in node.values():
_walk_fees(v, out)
elif isinstance(node, list):
for v in node:
_walk_fees(v, out)
Пять строк, и следующая смена формы ответа её не сломает. Судя по тому, как Ozon меняет API, это не паранойя.
Обход по дням
BY_DAY = "/v1/finance/accrual/by-day"
PAGES_PER_DAY = 200 # предохранитель
def iter_day(self, day: date):
types = self.types() # справочник type_id -> название, кешируется
last_id = ""
for _ in range(PAGES_PER_DAY):
body = {"date": day.isoformat()}
if last_id:
body["last_id"] = last_id
data = self.http.post(BY_DAY, body)
rows = data.get("accruals") or []
for a in rows:
if a.get("accrual_id"):
yield flatten_accrual(a, types)
last_id = data.get("last_id") or ""
if not last_id or not rows:
return
raise ApiError(-1, f"страниц за {day} больше {PAGES_PER_DAY}", BY_DAY)
def iter_operations(self, since: datetime, to: datetime):
d, last = since.date(), to.date()
while d <= last:
yield from self.iter_day(d)
d += timedelta(days=1)
Сигнатуру iter_operations(since, to) я сохранил специально: вызывающий код синка не узнал, что под ним поменялся API. Переписан один файл коннектора, sync.py не тронут.
Догрузка провала за две недели по трём кабинетам заняла минуты. 523 из 524 доставленных за это время заказов получили чистый доход; один оставшийся доставлен вчера, финансов по нему ещё нет, это нормально.
Что я бы сделал иначе
Метрика свежести на каждый источник данных. Не «шаг прошёл», а «когда последний раз приехало что-то новое». Это единственное, что поймало бы тихое отключение метода в первый же день.
Сверка на пересечении, а не на вере в документацию. Я потратил час на то, чтобы прогнать старые дни через новый метод и сравнить до копейки. Этот час сэкономил мне выяснение, почему у части заказов сумма «немного не такая» — через месяц, когда старых данных для сравнения уже не было бы.
Рекурсия вместо схемы там, где схема не ваша. Форму ответа стороннего API вы не контролируете. Код, который ходит по дереву и ищет знакомые узлы, переживает больше релизов, чем код, который знает точный путь.
Не доверять методу только потому, что он удобнее. accrual/postings выглядит как то, что нужно, и отдаёт цифру, которая почти совпадает. «Почти» в финансах — это несовпадение.
Если вы тоже строите что-то поверх Seller API Ozon и у вас есть свой опыт с финансовыми методами — особенно если знаете, куда в новом API делась обратная логистика отдельным полем (в старом было return_delivery_charge, в новом возврат приходит обычной услугой), — расскажите в комментариях. Я до сих пор не уверен, что нашёл все ямы.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.