Начало/Введение
Открыть бота

FastSub API

Одним API вы продаёте трафик, другим — покупаете подписчиков. Паблишер запрашивает задания и получает выплаты, рекламодатель заводит заказы и забирает подписчиков. Дальше оба целиком, по странице на метод: тело запроса, ответ и все ошибки, которые он умеет вернуть. Листать — кнопками внизу, стрелками на клавиатуре или свайпом; всё оглавление сразу — в списке слева. Если писать HTTP руками не хочется — есть SDK, он делает то же самое в четыре строки.

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

Скопировать всю документацию
Скачать .md
Быстрый старт SDK

Авторизация

В FastSub два независимых публичных API и, соответственно, два вида ключей. Оба передаются одинаково — заголовком Authorization: Bearer <ключ>, — но открывают разные наборы методов.

КлючКомуГде получитьЧто открывает
fsp_live_…Паблишер (владелец бота с трафиком) @FastSub_Robot → «Интеграция (API)» Всё под Publisher API
fsa_live_…Рекламодатель Бот рекламодателя → «Интеграция» Всё под Advertiser API
HTTP
# паблишер
Authorization: Bearer fsp_live_xxxxxxxxxxxxxxxx

# рекламодатель
Authorization: Bearer fsa_live_xxxxxxxxxxxxxxxx

Что важно знать про ключ паблишера

  • Ключ привязан к конкретному боту, а не к аккаунту: статистика, webhook-настройки и выданные задания считаются в рамках этого бота. Несколько ботов — несколько ключей.
  • Если ключ выпущен до привязки к боту, вы получите 401 token not bound to a bot — перевыпустите его в мини-аппе.
  • Ключ хранится у нас в виде хэша; показать «забытый» ключ нельзя, только перевыпустить (старый при этом отзывается сразу).
Ключ = полный доступ к аккаунту. Держите его на сервере, не в клиентском коде и не в репозитории. При компрометации сразу перевыпустите — старый ключ станет невалидным в тот же момент.

Ошибки авторизации (одинаковы для обоих API)

401 Unauthorized
# заголовка нет вообще
{ "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" }
403 / 404 — бот недоступен (только Publisher API)
# HTTP 403 — владелец выключил бота в кабинете
{ "detail": "bot is currently disabled by its owner" }

# HTTP 404 — бот, к которому был привязан ключ, удалён
{ "detail": "associated bot not found" }

Базовый URL и форматы

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-op60 / минВнутри TTL списка повторный вызов отдаётся из кэша и всё равно считается
POST /check-subscription300 / минЖивой запрос в Telegram
POST /check-resource300 / мин
POST /check-task300 / минПакетно — экономит лимит относительно поштучных проверок
GET /user/{id}/history120 / мин
GET /stats*60 / минОбщий счётчик на все пять методов stats
POST /webhook/configure, GET /webhook20 / минОбщий счётчик
GET /meОтдельного лимита нет
Advertiser: чтение (me, campaigns, stats, options, quote, список и карточка заказа, subscribers)120 / минОбщий счётчик
Advertiser: запись (POST/PATCH/DELETE заказов, дублирование)30 / минТрогают баланс и Telegram
Advertiser: subscribers.csv6 / минВыгрузка тяжёлая, берите редко и большими кусками
POST /advertiser/confirm-start600 / минВызывается из /start вашего бота на каждый запуск
429 Too Many Requests
# Retry-After: 60
{ "detail": "Rate limit exceeded: 60 requests per 60s" }
Лимитер fail-open: если наш Redis недоступен, запросы не режутся. Не стройте на этом логику — политика может измениться.

Формат ошибок

Форматов два, и это исторически осознанно: 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Окно прошло, подписка подтвержденаВыплата окончательна
expiredTTL списка истёк, юзер не подписался
unsubscribedrevertedОтписался до конца окна — начисление списывается с баланса; не хватило баланса — уходит в задолженность и гасится из следующих подписокСписание / долг
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 (не «просто по названию») — иначе нам нечем писать вашим юзерамКабинет паблишера → «Продать трафик» → добавить бота
Включить «Сервис шлёт блок ОП»Карточка бота → «Настройки»
Токен обязателен. Если его нет, переключатель не сохранится, а если токен позже отзовут в @BotFather — мы вернёмся к обычному режиму и отдадим ссылки в ответе (delivered: false). Обрабатывайте оба варианта, и ничего не сломается при смене настроек.

Что меняется в ответе

Тот же /request-op, тот же task_id, те же link_id. Разница — в двух местах: delivered: true и пустые ссылки.

200 OK — блок ОП отправлен нами
{
  "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"
    }
  ]
}
ПолеЗначение в этом режиме
deliveredtrue — сообщение отправлено (поставлено в очередь и уйдёт в течение секунды). Ничего не рисуйте
invite_link / start_linkВсегда null: единственная ссылка — в нашем сообщении
link_id / task_idНа месте: ими вы проверяете выполнение

Что увидит юзер

🔒 Доступ к боту

Подпишитесь на 2 спонсора ниже, чтобы продолжить.

▏После подписки вернитесь сюда и отправьте /start —
▏доступ откроется автоматически.

[ Подписаться: Example Channel ]
[ Подписаться: Нейросети каждый день ]
Кнопки только ссылочные. Кнопку с callback_data мы поставить не можем: callback придёт в ваш бот, где наш код не выполняется, и у юзера останется вечный «часик». Поэтому возврат — через /start, который есть у любого бота.

Вся интеграция

Python
JS
@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.verifiedHold пройден, начислено на балансНачислять юзеру бонусы, если у вас есть внутренняя экономика
resource.paidСумма выплачена паблишеруСверять выплаты
resource.unsubscribedЮзер отписался во время holdСнимать выданный бонус, если снимаете
resource.expiredTTL вышел, юзер не подписалсяЗакрывать задание в своём UI
resource.revertedНачисление отменено после отпискиКорректировать свою бухгалтерию

Тело доставки

Одинаково для всех resource.* событий — различается только event и заполненность временных меток.

JSON
{
  "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_idTelegram id вашего пользователя
campaign_resource_idВнутренний id рекламируемого ресурса — стабилен между выдачами, удобен для группировки
reward_rubПолная стоимость подписчика для рекламодателя
publisher_payout_rubВаш заработок (то же, что reward_for_publisher)
platform_commission_rubКомиссия платформы
*_atМетки жизненного цикла; те, что ещё не наступили, — null
timestampМомент формирования события
reasonПоявляется у некоторых негативных событий с пояснением
testtrue — тестовая доставка из мини-аппа. link_id у неё lnk_test_demo, деньги нулевые. Не обрабатывайте её как настоящую

Заголовки

ЗаголовокЗначение
X-FastSub-SignatureHMAC-SHA256 от тела, hex
X-FastSub-EventТип события
X-FastSub-Delivery-IdИдентификатор доставки — ключ идемпотентности. Одна и та же доставка при ретраях приходит с одним id
User-AgentFastSub-Webhook/1.0

Проверка подписи

Считайте HMAC по сырым байтам тела запроса — не по повторно сериализованному JSON, иначе подпись не сойдётся из-за порядка ключей и пробелов.

Python
JS
FastAPI
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_<ключ>. Ключ выдаётся в боте рекламодателя, один активный на аккаунт: перевыпуск сразу отзывает предыдущий.

HTTP
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_ageany×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_premiumboolean×1.3
require_photoboolean×1.1
require_usernameboolean×1.1
require_bioboolean×1.1
require_storiesboolean×1.1
min_publisher_rating0–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 недоступен, идемпотентность деградирует до «выключена», а не блокирует заказы. Совсем без повторов на своей стороне жить не стоит.
Python
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. Ветвитесь по ним, а не по тексту сообщения.

HTTPcodeЗначениеРетраить?
400invalid_orderЗаказ не проходит бизнес-правила: бюджет ниже минимального, неизвестная тематикаНет, чинить запрос
400invalid_resourceПроблема с ресурсом или ценой: ссылка не разбирается, цена вне диапазона, дневной лимит некорректенНет
400invalid_statusНеизвестный status в фильтре списка; допустимые — в details.allowedНет
400invalid_idempotency_keyКлюч длиннее 200 символовНет
400domain_errorПрочая доменная ошибка, текст в messageНет
402insufficient_fundsНе хватает баланса. details: required_rub, available_rub, missing_rubПосле пополнения
404order_not_foundЗаказ не существует или чужойНет
404chat_not_foundКанал/группа по ссылке не найденыНет
409order_conflictОперация несовместима с текущим статусом заказаНет
409resource_already_bookedРесурс уже занят другим вашим заказомНет
409checker_not_adminПроверочный бот не админ в ресурсеДа, после добавления бота
409idempotency_in_progressЗапрос с этим ключом ещё выполняетсяДа, через 2–5 с
422validation_errorТело не прошло схему. details.fields: поле → сообщениеНет
422invalid_targetingНекорректные значения таргетинга. details.fieldsНет
Python
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
Python-пакет или один файл — как удобнее. 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.
Python
JS
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);

Весь цикл целиком

Запросить задания, увести на веб-шаг, если он нужен, проверить выполнение, посмотреть баланс. Дальше по коду — те же методы, что описаны в справочнике.

Python
JS
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"].

Наш сбой не блокирует вашего юзера. Если API недоступен, хендлер получит 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 — то же, что по умолчанию, если умолчание переопределено в конструкторе.

Так было не всегда. До 2.0.0 мидлварь без флага не показывала ничего — ни блока, ни анкеты, ни редиректа, хотя API возвращал и то, и другое. Со стороны это выглядело как «SDK не выдаёт задания». Если вы держали хендлеры без флага намеренно, поставьте 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 о подтверждении подписки вы узнаёте только опросом. С ним — в тот момент, когда это случилось.

Подпись проверяйте всегда. Без неё ваш адрес принимает «подтверждение» от любого, кто его знает. И берите тело сырым: перекодированный JSON даст другую подпись, проверка не сойдётся.
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               # и пропустить юзера дальше

Расшифровка кодов — в справочнике ошибок: там же сказано, какие из них лечатся повтором, а какие означают, что чинить надо запрос.

Практика

Рабочие куски кода: клиент паблишера, бот с гейтом подписки, приём бот-стартов на стороне рекламодателя и общий справочник ошибок.

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

Минимальный клиент паблишера: запросить задания и проверить выполнение. Скопируйте и используйте как основу интеграции.

Python
JS
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), запуск подтверждает ваш собственный бот. Без этого кода подписчики не будут засчитываться, а заказ будет стоять на месте.

aiogram 3.x
python-telegram-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("Добро пожаловать!")
Не блокируйте пользователя ответом FastSub. Сначала отвечайте человеку, подтверждение отправляйте параллельно или из очереди. Вызов идемпотентен, так что безопасный повтор при сетевой ошибке — правильная стратегия.

Справочник ошибок

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Причина
401missing authorization headerНет заголовка Authorization
401invalid authorization scheme; expected BearerСхема не Bearer
401invalid or revoked tokenКлюч неверен или отозван
401account disabledАккаунт заблокирован
401token not bound to a bot — please regenerate…Старый ключ без привязки к боту
403bot is currently disabled by its ownerБот выключен владельцем
403this link_id belongs to a different publisherЧужой link_id
404associated bot not foundБот удалён
404link_id not found: …Несуществующий link_id
404bot not found for this accountЧужой bot_id в stats-запросе
400`from` must be earlier than or equal to `to`Перепутаны границы окна
429Rate 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 — по ним поднимается вся история.