FastSub API
Одним API вы продаёте трафик, другим — покупаете подписчиков. Паблишер запрашивает задания и получает выплаты, рекламодатель заводит заказы и забирает подписчиков. Дальше оба целиком, по странице на метод: тело запроса, ответ и все ошибки, которые он умеет вернуть. Листать — кнопками внизу, стрелками на клавиатуре или свайпом; всё оглавление сразу — в списке слева. Если писать HTTP руками не хочется — есть SDK, он делает то же самое в четыре строки.
Первый раз слышите про мотивированный трафик и не уверены, что здесь вообще происходит? Объяснение с нуля — на главной: кто кому платит, откуда берётся цена и почему деньги приходят не сразу. Здесь — только протокол.
Авторизация
В FastSub два независимых публичных API и, соответственно, два вида ключей.
Оба передаются одинаково — заголовком Authorization: Bearer <ключ>,
— но открывают разные наборы методов.
| Ключ | Кому | Где получить | Что открывает |
|---|---|---|---|
fsp_live_… | Паблишер (владелец бота с трафиком) | @FastSub_Robot → «Интеграция (API)» | Всё под Publisher API |
fsa_live_… | Рекламодатель | Бот рекламодателя → «Интеграция» | Всё под Advertiser API |
# паблишер Authorization: Bearer fsp_live_xxxxxxxxxxxxxxxx # рекламодатель Authorization: Bearer fsa_live_xxxxxxxxxxxxxxxx
Что важно знать про ключ паблишера
- Ключ привязан к конкретному боту, а не к аккаунту: статистика, webhook-настройки и выданные задания считаются в рамках этого бота. Несколько ботов — несколько ключей.
- Если ключ выпущен до привязки к боту, вы получите
401 token not bound to a bot— перевыпустите его в мини-аппе. - Ключ хранится у нас в виде хэша; показать «забытый» ключ нельзя, только перевыпустить (старый при этом отзывается сразу).
Ошибки авторизации (одинаковы для обоих API)
# заголовка нет вообще { "detail": "missing authorization header" } # не Bearer (Basic, Token, просто ключ без схемы…) { "detail": "invalid authorization scheme; expected Bearer" } # ключ не найден или отозван { "detail": "invalid or revoked token" } # аккаунт заблокирован администратором { "detail": "account disabled" } # только Publisher API: старый ключ без привязки к боту { "detail": "token not bound to a bot — please regenerate via @FastSub_Robot" }
# HTTP 403 — владелец выключил бота в кабинете { "detail": "bot is currently disabled by its owner" } # HTTP 404 — бот, к которому был привязан ключ, удалён { "detail": "associated bot not found" }
Базовый URL и форматы
https://fastsub.org/api/v1
Соглашения, общие для всех ответов
| Тема | Как устроено |
|---|---|
| Деньги | Всегда строка с точным десятичным значением
("1.5000"), а не число с плавающей точкой. Парсите как Decimal /
BigDecimal, не как float — иначе копейки поедут на больших объёмах |
| Время | ISO-8601 с таймзоной, всегда UTC:
"2026-05-23T18:04:11Z". Параметры from/to
без таймзоны трактуются как UTC |
Поле ok | Есть почти во всех ответах. ok:true
— запрос отработал. ok:false в теле с HTTP 200 бывает только у
/request-op: это не ошибка, а «нечего выдать» —
смотрите reason |
| Кодировка | UTF-8, тело запроса — JSON
(Content-Type: application/json) |
| Неизвестные поля | Мы можем добавлять новые поля в ответы без предупреждения. Клиент не должен падать на незнакомом ключе |
| Пагинация | limit + offset. Следующая
страница — offset += limit; признак конца — has_more
(Publisher API) или total (Advertiser API) |
Служебные методы
| Метод | Путь | Что делает |
|---|---|---|
GET | /health | Проверка живости: {"status":"ok"}. Без авторизации |
GET | / | Имя и версия сервиса. Без авторизации |
Лимиты запросов
Лимиты считаются по ключу (не по IP) в фиксированном окне в 60 секунд.
При превышении — 429 с заголовками Retry-After,
X-RateLimit-Limit, X-RateLimit-Window.
| Эндпоинт | Лимит | Комментарий |
|---|---|---|
POST /request-op | 60 / мин | Внутри TTL списка повторный вызов отдаётся из кэша и всё равно считается |
POST /check-subscription | 300 / мин | Живой запрос в Telegram |
POST /check-resource | 300 / мин | |
POST /check-task | 300 / мин | Пакетно — экономит лимит относительно поштучных проверок |
GET /user/{id}/history | 120 / мин | |
GET /stats* | 60 / мин | Общий счётчик на все пять методов stats |
POST /webhook/configure, GET /webhook | 20 / мин | Общий счётчик |
GET /me | — | Отдельного лимита нет |
Advertiser: чтение (me, campaigns, stats, options, quote, список и карточка заказа, subscribers) | 120 / мин | Общий счётчик |
Advertiser: запись (POST/PATCH/DELETE заказов, дублирование) | 30 / мин | Трогают баланс и Telegram |
Advertiser: subscribers.csv | 6 / мин | Выгрузка тяжёлая, берите редко и большими кусками |
POST /advertiser/confirm-start | 600 / мин | Вызывается из /start вашего бота на каждый запуск |
# Retry-After: 60 { "detail": "Rate limit exceeded: 60 requests per 60s" }
Формат ошибок
Форматов два, и это исторически осознанно: Publisher API отвечает в стиле FastAPI, а Orders API — конвертом с машиночитаемым кодом.
1. Publisher API и авторизация: detail
# HTTP 404 { "detail": "link_id not found: lnk_xxx" } # HTTP 422 — не прошла валидация тела { "detail": [ { "type": "greater_than_equal", "loc": ["body", "count"], "msg": "Input should be greater than or equal to 1" } ] }
2. Orders API: конверт {ok:false, error:{…}}
Применяется к путям /api/v1/advertiser/orders* и
/api/v1/advertiser/targeting*, а также к любой доменной ошибке
(нехватка средств, недоступный канал и т.п.). Ветвитесь по
error.code — он стабилен; error.message — текст для
человека и может меняться.
# HTTP 402 { "ok": false, "error": { "code": "insufficient_funds", "message": "Недостаточно средств на балансе.", "details": { "required_rub": "1500.0000", "available_rub": "220.0000", "missing_rub": "1280.0000" } } }
401 (авторизация)
и 429 (лимит) везде отвечают в формате {"detail": …} —
они срабатывают раньше, чем запрос доходит до бизнес-логики. Полный список кодов
— в разделах Ошибки Orders API и
Справочник ошибок.API ботов Publisher API
Ключ fsp_live_…. Сценарий целиком: запросить задания для юзера →
показать их → проверить выполнение → получить деньги на баланс. Всё остальное
(статистика, webhook, история) — вокруг этого цикла.
Как это работает
Прежде чем писать код, стоит понять три вещи: кто кому что должен, когда именно вам начисляются деньги и почему один и тот же спонсор может прийти в выдаче снова. Всё остальное — детали HTTP.
Кто участвует
| Роль | Что делает | Ключ |
|---|---|---|
| Паблишер — вы | Ваш бот просит у нас задания и показывает их своему юзеру. За подтверждённые подписки получаете выплаты | fsp_live_… |
| Рекламодатель | Заводит заказ: канал, цена за подписчика, сколько нужно, таргетинг | fsa_live_… |
| Конечный юзер | Ваш пользователь. Подписывается на спонсоров, чтобы получить доступ к вашему боту | — |
| FastSub | Подбирает заказы под юзера, проверяет подписки своими проверочными ботами, считает деньги и удержание | — |
Путь одного задания
# 1. ваш бот POST /request-op → task_id + список заданий (у каждого link_id) # 2. юзер жмёт кнопку и подписывается → наш проверочный бот видит вступление → выдача переходит в subscribed, начинается холд # 3. вы спрашиваете результат — любым из двух способов POST /check-subscription → живой вопрос Telegram прямо сейчас POST /check-task → сохранённые статусы всех заданий разом # 4. окно проверки отписок закрылось, юзер всё ещё подписан → verified: выплата окончательна
Статусы выдачи и что они значат для денег
| Статус | Что произошло | Деньги |
|---|---|---|
pending | Задание выдано, юзер пока не подписался | — |
subscribed | Подписался, идёт окно проверки отписок (до конца недели, сброс в воскресенье) | Начислено на баланс сразу — доступно к выводу |
verified | Окно прошло, подписка подтверждена | Выплата окончательна |
expired | TTL списка истёк, юзер не подписался | — |
unsubscribed → reverted | Отписался до конца окна — начисление списывается с баланса; не хватило баланса — уходит в задолженность и гасится из следующих подписок | Списание / долг |
invalid | Ресурс успели набрать другие, либо заказ отменили до того, как юзер подписался | — |
Ротация спонсоров: что придёт юзеру снова
Один и тот же канал может прийти юзеру повторно — если предыдущую выдачу он не «израсходовал». Это не баг, а то, что позволяет юзеру зарабатывать вам деньги дальше, а не выгорать за пару списков.
| Что было с прошлой выдачей | Придёт ли снова | Почему |
|---|---|---|
Юзер проигнорировал, выдача протухла (expired) | Да | Ничего не израсходовано: ни денег, ни слота в заказе |
| Подписался, но отписался до конца холда | Да | Выплаты не было, слот вернулся в заказ. Юзер может выполнить задание нормально |
Выдача invalid — ресурс уже набрали | Да | Если рекламодатель докупит подписчиков, задание снова станет доступным |
Задание оплачено (verified) — даже если юзер потом отписался | Нет, никогда | Рекламодатель купил подписчика, а не привычку подписываться. Иначе один человек продавался бы дважды |
Прямо сейчас есть живое предложение (pending в пределах TTL) или идёт холд | Нет | Одно открытое предложение на ресурс — иначе один юзер занял бы весь заказ |
Веб-шаг: почему первый запрос может не дать заданий
Незнакомому юзеру мы один раз показываем страницу — иначе о нём известно
только то, что он существует, и дорогие заказы с гео- или демо-таргетингом ему
недоступны. Ответ на такой запрос — reason: "onboarding_required" и
ссылка в onboarding_url. Подробно: /request-op.
Сколько вы получаете и когда
В reward_for_publisher уже лежит ваша сумма — цена
рекламодателя минус комиссия платформы. Она попадает в холд в момент подписки и
становится доступной к выводу после холда. Ставка комиссии зависит от вашего
удержания и падает с ростом качества трафика; VIP-паблишеры получают
фиксированную ставку. Сумма фиксируется в момент выдачи: если
рекламодатель потом поменяет цену, уже выданные задания оплатятся по старой.
Три способа узнать результат
| Способ | Когда использовать |
|---|---|
| /check-subscription | Кнопка «Проверить подписку»: спрашиваем Telegram прямо сейчас, ответ мгновенный |
| /check-task | Опрос в фоне: статусы всех заданий одного task_id одним запросом |
| Webhooks | Не спрашивать вообще: мы сами постучимся, когда статус изменится |
Режим «под ключ»: блок ОП отправляем мы
Обычно /request-op отдаёт вам ссылки, а сообщение со спонсорами
рисуете вы. Если это лишняя работа — выключите в настройках бота
«Сервис шлёт блок ОП», и мы будем отправлять блок вашему юзеру сами,
через ваш бот, его токеном. Ваш код при этом сокращается до одного вызова и
одного опроса.
Что нужно, чтобы включить
| Шаг | Где |
|---|---|
| Добавить бота по токену из @BotFather (не «просто по названию») — иначе нам нечем писать вашим юзерам | Кабинет паблишера → «Продать трафик» → добавить бота |
| Включить «Сервис шлёт блок ОП» | Карточка бота → «Настройки» |
delivered: false). Обрабатывайте
оба варианта, и ничего не сломается при смене настроек.Что меняется в ответе
Тот же /request-op, тот же task_id, те же
link_id. Разница — в двух местах: delivered: true и
пустые ссылки.
{
"ok": true,
"task_id": "tsk_a1b2c3d4e5f6",
"delivered": true,
"tasks": [
{
"link_id": "lnk_a1b2c3d4e5f6",
"type": "channel",
"task_type": "subscribe",
"button_name": "Подписаться",
"title": "Example Channel",
"theme_label": "Нейросети",
// ссылок нет: сообщение уже у юзера
"invite_link": null,
"start_link": null,
"reward_for_publisher": "1.5000"
}
]
}| Поле | Значение в этом режиме |
|---|---|
delivered | true — сообщение отправлено (поставлено в очередь и уйдёт в течение секунды). Ничего не рисуйте |
invite_link / start_link | Всегда null: единственная ссылка — в нашем сообщении |
link_id / task_id | На месте: ими вы проверяете выполнение |
Что увидит юзер
🔒 Доступ к боту Подпишитесь на 2 спонсора ниже, чтобы продолжить. ▏После подписки вернитесь сюда и отправьте /start — ▏доступ откроется автоматически. [ Подписаться: Example Channel ] [ Подписаться: Нейросети каждый день ]
callback_data мы
поставить не можем: callback придёт в ваш бот, где наш код не выполняется, и
у юзера останется вечный «часик». Поэтому возврат — через /start,
который есть у любого бота.Вся интеграция
@dp.message(CommandStart()) async def start(m: Message): data = await api_post("/request-op", {"user_id": m.from_user.id}) # веб-шаг: показываем ссылку и ждём возврата if data.get("reason") == "onboarding_required": return await m.answer("Остался шаг: " + data["onboarding_url"]) # заданий нет — пускаем if not data["ok"]: return await grant_access(m) # блок ОП уже отправлен нами — проверяем, всё ли выполнено st = await api_post("/check-task", {"task_id": data["task_id"]}) done = {"subscribed", "verified", "paid"} if all(i["status"] in done for i in st["items"]): await grant_access(m)
const data = await apiPost("/request-op", { user_id: userId }); if (data.reason === "onboarding_required") return send("Остался шаг: " + data.onboarding_url); if (!data.ok) return grantAccess(); // сообщение со спонсорами уже отправлено нами const st = await apiPost("/check-task", { task_id: data.task_id }); const done = ["subscribed", "verified", "paid"]; if (st.items.every(i => done.includes(i.status))) grantAccess();
Тонкости, о которых стоит знать
| Ситуация | Поведение |
|---|---|
| Юзер нажал кнопку ещё раз в пределах TTL | Список тот же (идемпотентность), и мы переотправим сообщение — обычно юзер просто не нашёл первое. Повторные отправки одного задания не чаще раза в минуту |
| Юзер заблокировал ваш бот | Telegram отклоняет отправку, мы пишем это в свои логи. Ответ вам всё равно delivered: true — заданий у юзера нет, и это нормально |
| Хочется рисовать блок самому | Выключите «Сервис шлёт блок ОП» — вернутся invite_link / start_link и delivered: false |
| Скрытие названий спонсоров | Работает и здесь: в кнопках будет «Канал №1 · Ниша» вместо настоящего имени |
Webhooks: события
Вместо постоянного опроса FastSub сам присылает события об изменении статуса выдач на ваш сервер. Настройка — через POST /webhook/configure или кнопкой в мини-аппе «Интеграция».
Все события
| Событие | Когда | Что делать |
|---|---|---|
resource.issued | Задание выдано юзеру | Синхронизировать своё состояние; денег ещё нет |
resource.subscribed | Юзер выполнил задание, начался hold | Выдавать контент, показывать «готово» |
resource.verified | Hold пройден, начислено на баланс | Начислять юзеру бонусы, если у вас есть внутренняя экономика |
resource.paid | Сумма выплачена паблишеру | Сверять выплаты |
resource.unsubscribed | Юзер отписался во время hold | Снимать выданный бонус, если снимаете |
resource.expired | TTL вышел, юзер не подписался | Закрывать задание в своём UI |
resource.reverted | Начисление отменено после отписки | Корректировать свою бухгалтерию |
Тело доставки
Одинаково для всех resource.* событий — различается только
event и заполненность временных меток.
{
"event": "resource.verified",
"link_id": "lnk_a1b2c3d4e5f6",
"task_id": "tsk_a1b2c3d4e5f6",
"user_id": 123456789,
"campaign_resource_id": 412,
"status": "verified",
"reward_rub": "2.0000",
"publisher_payout_rub": "1.5000",
"platform_commission_rub": "0.5000",
"issued_at": "2026-05-23T17:40:02Z",
"subscribed_at": "2026-05-23T17:41:19Z",
"verified_at": "2026-05-23T18:04:11Z",
"unsubscribed_at": null,
"expires_at": "2026-05-23T18:40:02Z",
"timestamp": "2026-05-23T18:04:11.914Z"
}Поля payload
| Поле | Описание |
|---|---|
event | Тип события; дублируется в заголовке X-FastSub-Event |
link_id / task_id | Те же идентификаторы, что выдал /request-op |
user_id | Telegram id вашего пользователя |
campaign_resource_id | Внутренний id рекламируемого ресурса — стабилен между выдачами, удобен для группировки |
reward_rub | Полная стоимость подписчика для рекламодателя |
publisher_payout_rub | Ваш заработок (то же, что reward_for_publisher) |
platform_commission_rub | Комиссия платформы |
*_at | Метки жизненного цикла; те, что ещё не наступили, — null |
timestamp | Момент формирования события |
reason | Появляется у некоторых негативных событий с пояснением |
test | true — тестовая доставка из мини-аппа. link_id у неё lnk_test_demo, деньги нулевые. Не обрабатывайте её как настоящую |
Заголовки
| Заголовок | Значение |
|---|---|
X-FastSub-Signature | HMAC-SHA256 от тела, hex |
X-FastSub-Event | Тип события |
X-FastSub-Delivery-Id | Идентификатор доставки — ключ идемпотентности. Одна и та же доставка при ретраях приходит с одним id |
User-Agent | FastSub-Webhook/1.0 |
Проверка подписи
Считайте HMAC по сырым байтам тела запроса — не по повторно сериализованному JSON, иначе подпись не сойдётся из-за порядка ключей и пробелов.
import hmac, hashlib def verify(body: bytes, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature)
import crypto from "node:crypto"; function verify(body, signature, secret) { const expected = crypto .createHmac("sha256", secret) .update(body).digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signature)); }
from fastapi import FastAPI, Request, HTTPException app = FastAPI() SECRET = "whsec_…" seen: set[str] = set() @app.post("/fastsub/webhook") async def hook(request: Request): body = await request.body() # сырые байты! sig = request.headers.get("X-FastSub-Signature", "") if not verify(body, sig, SECRET): raise HTTPException(401) # идемпотентность: повтор придёт с тем же Delivery-Id delivery = request.headers.get("X-FastSub-Delivery-Id", "") if delivery in seen: return {"ok": True} seen.add(delivery) event = await request.json() if event.get("test"): return {"ok": True} if event["event"] == "resource.verified": ... # начислить юзеру бонус return {"ok": True} # любой 2xx = принято
Ретраи и отключение
| Правило | Значение |
|---|---|
| Успех | Любой ответ 2xx. Тело нам не важно |
| Повторы | До 6 попыток с задержками 1 мин → 5 мин → 30 мин → 2 ч → 6 ч, дальше доставка помечается «мёртвой» |
| Автоотключение | После 50 неудач подряд webhook выключается (is_active: false), доставки перестают отправляться |
| Порядок | Не гарантируется. Ориентируйтесь на status и метки времени, а не на очерёдность прихода |
200. Долгий ответ — это таймаут,
таймаут — это повтор, а 50 повторов подряд выключат вам webhook.API заказов Advertiser API
Ключ fsa_live_…. Всё, что рекламодатель делает в боте, доступно
и здесь: посчитать стоимость, создать заказ, менять его, докупать подписчиков,
выгружать аудиторию. Заказ (order) — это одна кампания и её ресурс.
Ключ рекламодателя
Все методы Advertiser API требуют Authorization: Bearer fsa_live_<ключ>.
Ключ выдаётся в боте рекламодателя, один активный на аккаунт: перевыпуск сразу
отзывает предыдущий.
Authorization: Bearer fsa_live_xxxxxxxxxxxxxxxx Content-Type: application/json
Как устроен заказ
Один заказ = одна кампания + один рекламируемый ресурс. У заказа есть базовая цена за подписчика, количество, таргетинг и — как следствие — коэффициент и итоговый бюджет. Бюджет резервируется с баланса в момент создания; неизрасходованный остаток возвращается при отмене или завершении.
Статусы заказа
| status | Значение | Что можно |
|---|---|---|
draft | Черновик (создаётся только внутри бота) | Читать |
moderation | Ждёт проверки модератором | Менять, отменять с полным возвратом |
active | Крутится, подписчики набираются | Менять, ставить на паузу, докупать, отменять |
paused | На паузе — выдача остановлена | Снять с паузы, отменить. Таргетинг менять нельзя |
completed | Все подписчики набраны | Только читать и дублировать |
rejected | Отклонён модератором, деньги возвращены | Читать rejection_reason, дублировать |
canceled | Отменён вами | Читать, дублировать |
paused. Флаг paused в
ответах — не то же самое, что статус: он true и для заказа на
паузе, и для заказа, который ждёт модерации с выключенным автостартом
("paused": true при создании). Так вы заранее знаете, что после
одобрения он не поедет сам.Коэффициенты и лимиты
Цена подписчика = базовая цена × произведение коэффициентов. Итог
ограничен сверху значением max_coefficient. Актуальные числа всегда
можно получить из /targeting/options — таблица ниже
дана для понимания механики.
Поля таргетинга
| Поле | Значения | Множитель |
|---|---|---|
min_age | any | ×1.0 |
min_14_plus | ×1.2 | |
min_16_plus | ×1.5 | |
min_18_plus | ×2.0 | |
genders | массив из male, female, undisclosed | ×1.1, если список непустой |
countries | массив из RU, UA, BY, KZ, OTHER | ×1.5, если список непустой |
require_premium | boolean | ×1.3 |
require_photo | boolean | ×1.1 |
require_username | boolean | ×1.1 |
require_bio | boolean | ×1.1 |
require_stories | boolean | ×1.1 |
min_publisher_rating | 0–10 | —, на цену не влияет |
базовая цена 1.00 ₽ countries: ["RU"] × 1.5 min_age: min_18_plus × 2.0 require_premium: true × 1.3 ───────────────────────────── коэффициент = 3.9 цена за подписчика = 3.90 ₽ × 1000 подписчиков = 3900.00 ₽
Лимиты
| Параметр | Значение |
|---|---|
| Базовая цена за подписчика | 0.50 – 25 ₽ |
| Количество подписчиков в заказе | 100 – 100 000 |
| Минимальный бюджет заказа | 300 ₽ |
| Минимальный дневной лимит | 10 выдач/сутки |
| Максимальный коэффициент | ×5.0 |
| Длина названия | 3 – 255 символов |
null в targeting
отбрасываются, поэтому {"require_premium": null} и отсутствие поля —
одно и то же. Незнакомое поле вызовет ошибку валидации: схема закрытая.Идемпотентность создания
Создание заказа резервирует деньги. Если соединение оборвалось после того, как
мы всё записали, но до того, как ответ до вас дошёл, наивный повтор купил бы —
и списал — дважды. Заголовок Idempotency-Key это закрывает.
| Ситуация | Что вернём |
|---|---|
| Первый запрос с этим ключом | Заказ создаётся, 201 |
| Повтор после успеха | Тот же самый заказ, без второго списания |
| Повтор, пока первый ещё выполняется | 409 idempotency_in_progress — подождите пару секунд и повторите |
| Повтор после ошибки | Ключ освобождён — запрос выполнится заново |
Правила
- Ключ — любая уникальная строка до 200 символов (UUID подойдёт).
Длиннее —
400 invalid_idempotency_key. - Ключи изолированы по аккаунтам: чужой ключ не пересечётся с вашим.
- Ответ хранится 24 часа, дальше тот же ключ создаст новый заказ.
- Генерируйте ключ один раз на намерение и переиспользуйте при ретраях — новый ключ на каждую попытку полностью обесценивает механизм.
- Если наш Redis недоступен, идемпотентность деградирует до «выключена», а не блокирует заказы. Совсем без повторов на своей стороне жить не стоит.
import httpx, uuid, time key = str(uuid.uuid4()) # один на весь цикл повторов payload = {"link": "@example", "name": "Запуск", "price_rub": "1.00", "quantity": 1000} for attempt in range(5): r = httpx.post( "https://fastsub.org/api/v1/advertiser/orders", headers={"Authorization": "Bearer fsa_live_xxx", "Idempotency-Key": key}, json=payload, timeout=30, ) if r.status_code == 409 and \ r.json()["error"]["code"] == "idempotency_in_progress": time.sleep(2) continue break
Ошибки Orders API
Все коды, которые может вернуть error.code. Ветвитесь по ним, а
не по тексту сообщения.
| HTTP | code | Значение | Ретраить? |
|---|---|---|---|
400 | invalid_order | Заказ не проходит бизнес-правила: бюджет ниже минимального, неизвестная тематика | Нет, чинить запрос |
400 | invalid_resource | Проблема с ресурсом или ценой: ссылка не разбирается, цена вне диапазона, дневной лимит некорректен | Нет |
400 | invalid_status | Неизвестный status в фильтре списка; допустимые — в details.allowed | Нет |
400 | invalid_idempotency_key | Ключ длиннее 200 символов | Нет |
400 | domain_error | Прочая доменная ошибка, текст в message | Нет |
402 | insufficient_funds | Не хватает баланса. details: required_rub, available_rub, missing_rub | После пополнения |
404 | order_not_found | Заказ не существует или чужой | Нет |
404 | chat_not_found | Канал/группа по ссылке не найдены | Нет |
409 | order_conflict | Операция несовместима с текущим статусом заказа | Нет |
409 | resource_already_booked | Ресурс уже занят другим вашим заказом | Нет |
409 | checker_not_admin | Проверочный бот не админ в ресурсе | Да, после добавления бота |
409 | idempotency_in_progress | Запрос с этим ключом ещё выполняется | Да, через 2–5 с |
422 | validation_error | Тело не прошло схему. details.fields: поле → сообщение | Нет |
422 | invalid_targeting | Некорректные значения таргетинга. details.fields | Нет |
def handle(r: httpx.Response): if r.is_success: return r.json() body = r.json() # 401/429 приходят в стиле FastAPI, остальное — конвертом if "error" not in body: raise RuntimeError(body.get("detail", r.text)) code = body["error"]["code"] if code == "insufficient_funds": need = body["error"]["details"]["missing_rub"] raise RuntimeError(f"пополните баланс на {need} ₽") if code == "checker_not_admin": raise RuntimeError("добавьте нашего бота админом и повторите") raise RuntimeError(f"{code}: {body['error']['message']}")
SDK
Всё, что было до этой страницы, — это HTTP. Если не хочется собирать запросы руками, возьмите готовый клиент: один файл, одна зависимость, те же самые методы под своими именами. Дальше — как им пользоваться.
Зачем он нужен
Справочник описывает протокол, а в боте вы пишете код. SDK убирает
промежуточный слой: не «собрать JSON, поставить заголовок, разобрать ответ», а
fs.request_op(user_id=...). Ошибки приезжают исключением, ответ —
объектом с понятными полями, повторы при наших сбоях клиент берёт на себя.
Что есть
| Файл | Что внутри | Зависимости |
|---|---|---|
| fastsub.py | FastSub — асинхронный клиент, FastSubSync — синхронный, FastSubMiddleware для aiogram, приёмник webhook, FastSubAdvertiser для заказов | httpx |
| fastsub.mjs | Клиент паблишера и рекламодателя на JS, middleware для grammY и Telegraf, приёмники webhook для Hono/Bun/Express/Fastify: Node 18+, Bun, Deno | нет |
| fastsub.d.ts | Типы к fastsub.mjs: положите рядом — получите
автодополнение и проверку типов, ничего собирать не надо |
нет |
| /sdk/examples | Список готовых примеров: боты целиком, скрипт настройки, curl | — |
pip install fastsub либо скачайте fastsub.py и положите
рядом с ботом. Второй способ удобен, когда деплой — это «закинуть папку на
сервер», и никакого индекса пакетов там нет. На JS то же самое:
npm install fastsub или один fastsub.mjs.Что SDK делает за вас
Это не только обёртка над HTTP. Вот то, что иначе пишется руками в каждой интеграции — и чаще всего пишется с ошибкой:
| Вместо | В SDK |
|---|---|
Цикл по link_id: пятьдесят запросов и пятьдесят единиц лимита |
fs.check_many([...]) — до 50 статусов одним запросом |
Свой цикл со счётчиком offset по страницам |
iter_orders, iter_subscribers,
iter_user_history |
os.environ["FASTSUB_KEY"] в первой строке каждого бота |
FastSub.from_env() |
Свой повтор на 429 и 5xx с чтением
Retry-After |
Встроен в оба клиента; max_retries=0 отключает |
if event["event"] == "resource.verified" по строкам |
match по классам события и WebhookEvents |
| Проверка подписи и дедупликация доставок вручную | Готовые приёмники для FastAPI, aiohttp, Flask, Express, Hono |
Установка и первый вызов
Асинхронный клиент — для aiogram и всего на asyncio. Синхронный
(FastSubSync) — для pyTelegramBotAPI, Flask и прочего, где корутин нет.
Методы у них одинаковые, разница только в await.
FastSub.from_env() возьмёт его из FASTSUB_KEY, а адрес —
из необязательного FASTSUB_BASE_URL. Переменной нет — упадёт сразу и
скажет какой, а не KeyError посреди первого запроса. У клиента
рекламодателя своя переменная — FASTSUB_ADVERTISER_KEY.pip install fastsub # Асинхронный бот (aiogram): from fastsub import FastSub async with FastSub(api_key="fsp_live_...") as fs: answer = await fs.request_op(user_id=123456789) # Синхронный бот (pyTelegramBotAPI, Flask и всё, что не на asyncio): from fastsub import FastSubSync, telebot_gate fs = FastSubSync(api_key="fsp_live_...") gate = telebot_gate(fs, bot) @bot.message_handler(commands=["start"]) def start(message): if not gate(message.from_user.id, message.chat.id): return # блок показан, дальше не пускаем bot.send_message(message.chat.id, "Доступ открыт")
// npm install fastsub — или скачайте fastsub.mjs, он без зависимостей // Node 18+ / Bun / Deno import { FastSub } from "fastsub"; const fs = new FastSub({ apiKey: "fsp_live_..." }); const answer = await fs.requestOp({ userId: 123456789 }); // На TypeScript положите рядом fastsub.d.ts — будет автодополнение: // /api/v1/sdk/js/types const status = await fs.checkTask(answer.taskId); if (status.allDone) console.log("пускаем"); else console.log("осталось:", status.remaining.length);
Весь цикл целиком
Запросить задания, увести на веб-шаг, если он нужен, проверить выполнение, посмотреть баланс. Дальше по коду — те же методы, что описаны в справочнике.
from fastsub import FastSub, FastSubError # Ключ уже есть — просто клиент: async with FastSub(api_key="fsp_live_...") as fs: # 1. Задания для юзера. Передавайте всё, что знаете о нём: # откроются заказы с таргетингом — они дороже. answer = await fs.request_op( user_id=123456789, count=3, has_telegram_premium=True, has_username=True, ) if answer.needs_web_step: # Один шаг на нашей странице, она сама вернёт юзера в бот. send(answer.onboarding_url) elif answer.has_tasks: for task in answer.tasks: print(task.title, task.link, task.reward_for_publisher) else: # Почему пусто — человеческим текстом, годится в логи. print(answer.explain) # 2. Проверить одно задание. check_subscription спрашивает Telegram # сейчас, check_task читает сохранённый статус (дешевле). data = await fs.check_subscription("lnk_a1b2c3d4e5f6") if data["subscribed"]: grant_access() # 3. Баланс, настройки бота. me = await fs.me() print(me["balance_rub"], me["debt_rub"])
import { FastSub } from "fastsub"; const fs = new FastSub({ apiKey: "fsp_live_..." }); const answer = await fs.requestOp({ userId: 123456789, count: 3, hasTelegramPremium: true, }); if (answer.needsWebStep) send(answer.onboardingUrl); else if (answer.hasTasks) for (const t of answer.tasks) console.log(t.title, t.link); else console.log(answer.explain); const data = await fs.checkSubscription("lnk_a1b2c3d4e5f6"); if (data.subscribed) grantAccess();
middleware для aiogram
Три строки вместо вызова в каждом хендлере. middleware сам просит
задания, сам рисует блок и кладёт ответ в data["fastsub"].
None и обычный поток продолжится.
Реклама не должна ломать продукт.from aiogram import Dispatcher from fastsub import FastSub, FastSubMiddleware, fastsub_check_router fs = FastSub(api_key="fsp_live_...") dp = Dispatcher() # Спрашивает задания сам, рисует блок и кладёт ответ в data["fastsub"]. dp.message.middleware(FastSubMiddleware(fs)) dp.callback_query.middleware(FastSubMiddleware(fs)) dp.include_router(fastsub_check_router(fs)) # кнопка «Проверить» @dp.message() async def any_message(message, fastsub): # Уже всё сделано: если юзер не выполнил задания, сюда мы не дошли. # fastsub — это OpAnswer или None, если наш API был недоступен. await message.answer("Ваш обычный ответ")
Флаги: где звать, а где не надо
По умолчанию мидлварь гейтит: незнакомого юзера уводит на веб-шаг, юзеру
с невыполненными заданиями показывает блок, и до хендлера дело не доходит. Флаг
fastsub нужен для исключений. skip — не звать API вообще:
помощь, платежи, поддержка. default — позвать, положить ответ в
data и пропустить: блок рисуете вы сами. gate — то же, что
по умолчанию, если умолчание переопределено в конструкторе.
FastSubMiddleware(fs, mode="default").# Где реклама не нужна — помечаем хендлер: @dp.message(Command("help"), flags={"fastsub": "skip"}) async def help_cmd(message): await message.answer("Помощь без блока спонсоров") # А где юзера надо остановить до выполнения — "gate": @dp.message(Command("premium"), flags={"fastsub": "gate"}) async def premium(message): await message.answer("Доступно после заданий")
Режимы выдачи
Что делать с тем, что юзер уже держит. По умолчанию keep — тот же список до конца TTL.
top_up закрывает самую частую жалобу: юзер сделал два задания из трёх,
просит ещё — и получает те же три, включая выполненные. С top_up он
видит одно своё и два новых.
| Режим | Что вернёт | Когда брать |
|---|---|---|
| keep | Тот же список до конца TTL | По умолчанию |
| top_up | Невыполненные плюс новые, до нужного числа | Юзер возвращается за добавкой |
| fresh | Пересобрать список заново | Блок показываете вы сами и решаете, когда обновить |
| rotate | Новая порция после выполнения | Юзер приходит за следующей пачкой |
# Юзер выполнил два задания из трёх и просит ещё. # keep вернёт те же три — включая выполненные. top_up добьёт до нужного числа: answer = await fs.request_op(user_id=user_id, count=3, mode="top_up") # «Пересобрать» — когда блок показываете вы сами и решаете, когда обновить: answer = await fs.request_op(user_id=user_id, mode="fresh") # «Обновлять после выполнения» — юзер возвращается за новой порцией: answer = await fs.request_op(user_id=user_id, mode="rotate")
Приём webhook
Без webhook о подтверждении подписки вы узнаёте только опросом. С ним — в тот момент, когда это случилось.
from fastapi import FastAPI from fastsub import ( ResourceReverted, ResourceUnsubscribed, ResourceVerified, WebhookEvents, fastsub_webhook_router, ) app = FastAPI() # Один раз: куда слать события. Секрет вернётся ровно один раз — сохраните. result = await fs.configure_webhook( "https://your-bot.example/fastsub/webhook", events=["resource.verified", "resource.reverted"], ) SECRET = result["secret"] events = WebhookEvents() @events.on("resource.verified") async def paid(event: ResourceVerified): await credit(event.user_id, event.publisher_payout_rub) @events.on("resource.unsubscribed", "resource.reverted") async def taken_back(event: ResourceUnsubscribed | ResourceReverted): if event.money_was_taken: # отписался внутри холда await debit(event.user_id, event.publisher_payout_rub) # Подпись проверена, повторные доставки отсеяны, 2xx отвечен. app.include_router(fastsub_webhook_router(SECRET, events))
ResourceIssued, ResourceSubscribed,
ResourceVerified, ResourcePaid,
ResourceUnsubscribed, ResourceExpired,
ResourceReverted — то есть можно match вместо сравнения
строк, где опечатка не ошибка, а ветка, которая никогда не выполнится. Незнакомое
событие остаётся базовым WebhookEvent: мы их иногда добавляем, и
старый SDK от этого падать не должен.X-FastSub-Delivery-Id; для нескольких воркеров передайте свою
функцию — dedupe=lambda d: redis.set(f"fs:{d}", 1, nx=True, ex=86400).Хотите принимать руками — verify_webhook(secret, body, signature)
никуда не делся. Тело берите сырым: перекодированный JSON даст другую подпись.
Полный список событий и их полей — в разделе
Webhooks: события. Для aiohttp вместо роутера FastAPI
возьмите fastsub_aiohttp_handler(secret, handler).
Клиент рекламодателя
Заказы, докупка, выгрузка подписчиков и оплата целевых действий — тем же
ключом fsa_live_…, что лежит в мини-аппе «Интеграция». В карточке
заказа приезжают прогноз выполнения и удержание подписчиков.
from fastsub import FastSubAdvertiser async with FastSubAdvertiser(api_key="fsa_live_...") as adv: # Сначала цена, потом деньги: quote ничего не списывает. quote = await adv.quote(quantity=1000, price_rub="1.50") print(quote["total_rub"], quote["coefficients"]) # Повтор с тем же ключом вернёт созданный заказ, а не второй такой же. order = await adv.create_order( name="Мой канал", chat="@my_channel", quantity=1000, price_rub="1.50", idempotency_key="my-order-42", ) card = await adv.order(order["id"]) print(card["forecast"]["text"]) # «Около 9 ч при текущем темпе» print(card["retention"]["summary"]) # «Удержание: 1 дн — 92%, 7 дн — 84%» # Оплата целевого действия — за регистрацию, депозит, что угодно ваше. await adv.postback(link_id="lnk_...", event="registration", value_rub=25)
Списки — итератором, а не циклом со счётчиком
Заказы и подписчики отдаются страницами. Обход страниц у SDK свой, вместе с выходом на последней — там, где в самописном цикле чаще всего и ошибаются.
async for order in adv.iter_orders(status="active"): print(order["id"], order["progress"]) # Заказ на десять тысяч человек — это сотня страниц. async for sub in adv.iter_subscribers(order_id): await crm.upsert(sub["user_id"])
targeting_options() меняется примерно раз в месяц, а зовут его перед
каждым расчётом цены — тысяча котировок была тысячей одинаковых ответов. Нужен
свежий прямо сейчас — targeting_options(fresh=True).Ошибки и повторы
Всё, что вернулось не 2xx, поднимается как FastSubError: в нём
status, detail и разобранный payload.
detail написан по-человечески — его можно показать пользователю или
положить в лог как есть.
| Что случилось | Что делает клиент |
|---|---|
429 — превышен лимит |
Повторяет сам, подождав столько, сколько написано в Retry-After |
5xx и обрывы связи | Повторяет сам |
4xx — кроме 429 |
Не повторяет никогда: запрос не станет правильнее от повтора |
# Наш сбой не должен останавливать вашего бота. try: answer = await fs.request_op(user_id=user_id) except FastSubError as e: print(e.status, e.detail) # detail можно показать человеку answer = None # и пропустить юзера дальше
Расшифровка кодов — в справочнике ошибок: там же сказано, какие из них лечатся повтором, а какие означают, что чинить надо запрос.
Практика
Рабочие куски кода: клиент паблишера, бот с гейтом подписки, приём бот-стартов на стороне рекламодателя и общий справочник ошибок.
Быстрый старт
Минимальный клиент паблишера: запросить задания и проверить выполнение. Скопируйте и используйте как основу интеграции.
import httpx API = "https://fastsub.org/api/v1" TOKEN = "fsp_live_xxx" HEADERS = {"Authorization": f"Bearer {TOKEN}"} def request_tasks(user_id: int, count: int = 3, **audience) -> dict: # Запросить задания. audience: has_telegram_premium, has_username, … r = httpx.post(f"{API}/request-op", headers=HEADERS, json={"user_id": user_id, "count": count, **audience}, timeout=15) r.raise_for_status() return r.json() def check_live(link_id: str) -> bool: # Живая проверка по кнопке «Проверить подписку» r = httpx.post(f"{API}/check-subscription", headers=HEADERS, json={"link_id": link_id}, timeout=15) r.raise_for_status() return r.json()["subscribed"] def check_all(task_id: str) -> list[dict]: # Пакетно: статусы всех ссылок одного задания r = httpx.post(f"{API}/check-task", headers=HEADERS, json={"task_id": task_id}, timeout=15) r.raise_for_status() return r.json()["items"]
const API = "https://fastsub.org/api/v1"; const TOKEN = "fsp_live_xxx"; const HEADERS = { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" }; async function post(path, body) { const r = await fetch(`${API}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) }); const data = await r.json(); if (!r.ok) throw new Error(data.detail || r.status); return data; } const requestTasks = (userId, count = 3, audience = {}) => post("/request-op", { user_id: userId, count, ...audience }); const checkLive = async (linkId) => (await post("/check-subscription", { link_id: linkId })).subscribed;
Пример бота с гейтом подписки
Рабочий Telegram-бот на aiogram 3.x: онбординг, выдача спонсоров, живая проверка и допуск к контенту только после выполнения всех заданий.
import asyncio import httpx from aiogram import Bot, Dispatcher, F from aiogram.filters import CommandStart from aiogram.types import ( Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, ) BOT_TOKEN = "123456:ABC..." API = "https://fastsub.org/api/v1" TOKEN = "fsp_live_xxx" HEADERS = {"Authorization": f"Bearer {TOKEN}"} bot = Bot(BOT_TOKEN) dp = Dispatcher() async def api_post(path: str, payload: dict) -> dict: async with httpx.AsyncClient(timeout=15) as c: r = await c.post(f"{API}{path}", headers=HEADERS, json=payload) r.raise_for_status() return r.json() @dp.message(CommandStart()) async def start(message: Message): kb = InlineKeyboardMarkup(inline_keyboard=[[ InlineKeyboardButton(text="Получить доступ", callback_data="get_tasks") ]]) await message.answer("Подпишитесь на спонсоров, чтобы продолжить:", reply_markup=kb) @dp.callback_query(F.data == "get_tasks") async def get_tasks(cb: CallbackQuery): u = cb.from_user data = await api_post("/request-op", { "user_id": u.id, "count": 3, # без этих полей часть кампаний вам просто не выдадут "has_telegram_premium": bool(u.is_premium), "has_username": bool(u.username), }) if not data["ok"]: # ВАЖНО: ветвимся по reason, а не просто «не ok — пускаем» if data.get("reason") == "onboarding_required": kb = InlineKeyboardMarkup(inline_keyboard=[[ InlineKeyboardButton(text="Пройти регистрацию", url=data["onboarding_url"]) ]]) await cb.message.answer("Остался один шаг:", reply_markup=kb) else: await grant_access(cb.message) # no_tasks — пускаем await cb.answer() return if data.get("delivered"): # режим «под ключ»: блок ОП мы уже отправили сами await cb.answer() return for task in data["tasks"]: link = task["invite_link"] or task["start_link"] action = "Забустить" if task["task_type"] == "boost" \ else ("Запустить" if task["task_type"] == "start_bot" else "Подписаться") kb = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=action, url=link)], [InlineKeyboardButton(text="Проверить", callback_data=f"check:{task['link_id']}")], ]) await cb.message.answer(task["title"], reply_markup=kb) # task_id пригодится, чтобы проверить всё разом await cb.message.answer( "Когда закончите — нажмите «Готово»", reply_markup=InlineKeyboardMarkup(inline_keyboard=[[ InlineKeyboardButton(text="Готово", callback_data=f"done:{data['task_id']}") ]])) await cb.answer() @dp.callback_query(F.data.startswith("check:")) async def check_one(cb: CallbackQuery): link_id = cb.data.split(":", 1)[1] data = await api_post("/check-subscription", {"link_id": link_id}) if data["subscribed"]: await cb.answer("Засчитано!", show_alert=True) elif data["reason"] == "unsupported_task_type": await cb.answer("Запуск бота засчитается автоматически", show_alert=True) elif data["reason"] == "check_unavailable": await cb.answer("Проверка недоступна, попробуйте через минуту", show_alert=True) else: await cb.answer("Не вижу подписку. Подпишитесь и нажмите ещё раз", show_alert=True) @dp.callback_query(F.data.startswith("done:")) async def check_all(cb: CallbackQuery): task_id = cb.data.split(":", 1)[1] data = await api_post("/check-task", {"task_id": task_id}) done = {"subscribed", "verified", "paid"} left = [i for i in data["items"] if i["status"] not in done] if left: titles = ", ".join(i["title"] or "—" for i in left) await cb.answer(f"Осталось: {titles}", show_alert=True) return await grant_access(cb.message) await cb.answer() async def grant_access(message: Message): await message.answer("Доступ открыт. Держите контент 🎁") async def main(): await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main())
subscribed — юзер подписан
и идёт hold-период, verified — подписка подтверждена и деньги
начислены, paid — уже выплачены. Для пользователя все три означают
одно: задание выполнено.Приём бот-стартов (рекламодателю)
Если вы продвигаете бота (задание start_bot), запуск
подтверждает ваш собственный бот. Без этого кода подписчики не будут
засчитываться, а заказ будет стоять на месте.
import httpx from aiogram import Bot, Dispatcher from aiogram.filters import CommandObject, CommandStart from aiogram.types import Message FASTSUB = "https://fastsub.org/api/v1/advertiser/confirm-start" KEY = "fsa_live_xxx" dp = Dispatcher() @dp.message(CommandStart()) async def start(message: Message, command: CommandObject): payload = (command.args or "").strip() if payload.startswith("fastsub_"): try: async with httpx.AsyncClient(timeout=10) as c: r = await c.post( FASTSUB, headers={"Authorization": f"Bearer {KEY}"}, json={"start_param": payload, "user_id": message.from_user.id}, ) # 409 = ссылку выдали другому человеку; 4xx не ретраим if r.status_code >= 500: ... # положить в очередь на повтор except httpx.HTTPError: ... # сеть моргнула — тоже в очередь # пользователю отвечаем в любом случае await message.answer("Добро пожаловать!")
import httpx from telegram import Update from telegram.ext import ContextTypes FASTSUB = "https://fastsub.org/api/v1/advertiser/confirm-start" KEY = "fsa_live_xxx" async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): payload = context.args[0] if context.args else "" if payload.startswith("fastsub_"): async with httpx.AsyncClient(timeout=10) as c: await c.post( FASTSUB, headers={"Authorization": f"Bearer {KEY}"}, json={"start_param": payload, "user_id": update.effective_user.id}, ) await update.message.reply_text("Добро пожаловать!")
Справочник ошибок
HTTP-статусы, общие для обоих API
| Код | Значение | Что делать |
|---|---|---|
200 | Успех | У /request-op дополнительно проверьте ok и reason |
201 | Создано (заказ, копия заказа) | — |
400 | Запрос корректен по схеме, но неверен по смыслу | Чинить данные, не ретраить |
401 | Проблема с ключом | Проверить заголовок, перевыпустить ключ |
402 | Не хватает средств (Advertiser API) | Пополнить баланс и повторить |
403 | Ресурс существует, но не ваш; либо бот выключен | Не ретраить |
404 | Объект не найден | Не ретраить |
409 | Конфликт состояния или идемпотентности | Только idempotency_in_progress имеет смысл повторить |
422 | Тело не прошло валидацию | Чинить запрос |
429 | Превышен лимит | Подождать Retry-After секунд |
5xx | Наша проблема | Ретрай с экспоненциальной задержкой |
Publisher API: detail
| Код | detail | Причина |
|---|---|---|
401 | missing authorization header | Нет заголовка Authorization |
401 | invalid authorization scheme; expected Bearer | Схема не Bearer |
401 | invalid or revoked token | Ключ неверен или отозван |
401 | account disabled | Аккаунт заблокирован |
401 | token not bound to a bot — please regenerate… | Старый ключ без привязки к боту |
403 | bot is currently disabled by its owner | Бот выключен владельцем |
403 | this link_id belongs to a different publisher | Чужой link_id |
404 | associated bot not found | Бот удалён |
404 | link_id not found: … | Несуществующий link_id |
404 | bot not found for this account | Чужой bot_id в stats-запросе |
400 | `from` must be earlier than or equal to `to` | Перепутаны границы окна |
429 | Rate limit exceeded: N requests per 60s | Превышен лимит метода |
reason в теле при ok:false (/request-op)
| reason | Значение |
|---|---|
no_tasks | Нет подходящих заданий — пускайте юзера дальше |
onboarding_required | Юзер не прошёл веб-шаг — покажите onboarding_url и не пускайте |
bot_disabled | Ваш бот-партнёр отключён |
Advertiser Orders API: error.code
Полная таблица с рекомендациями по ретраям — в разделе Ошибки Orders API.
link_id / order_id и время запроса в UTC — по ним
поднимается вся история.