FanLead Billing API
API v1

Документация интеграции

FanLead Billing API

ИИ-агентам и автоматическим инструментам: открыть статическую Markdown-версию.

Единый способ принимать оплату во всех сервисах ФАНлид: сервис создаёт тариф и checkout, Billing показывает страницу оплаты, проверяет ЮKassa, ведёт подписку и отправляет подписанный webhook.

Подключение за 6 шагов

  1. 1
    Войти и получить Admin Key

    Владелец сервиса входит или регистрируется через FanLead ID в https://billing.fanlead.ru/admin. Billing автоматически создаёт его отдельный кабинет, где можно один раз выпустить и скопировать постоянный Admin Key.

  2. 2
    Создать проект

    Backend с Admin Key передаёт название, логотип, адрес возврата и webhook URL. В ответ получает Service Key и Webhook Secret.

  3. 3
    Создать тариф

    Проект задаёт цену за месяц, периоды 1/3/6/12 месяцев, скидки и доступность автопродления.

  4. 4
    Создать checkout

    Backend проекта передаёт разрешённые тарифы и периоды, варианты по умолчанию, свой ID заказа, ресурс и пользователя. Billing возвращает публичную ссылку с безопасным выбором.

  5. 5
    Принять webhook

    После проверки платежа Billing отправляет событие. Проект проверяет HMAC, timestamp и уникальность eventId.

  6. 6
    Выдать доступ

    Проект атомарно сохраняет событие и включает продуктовый тариф до даты currentPeriodEnd.

Что передать новому сервису

Владелец входит в свой кабинет через FanLead ID, выпускает персональный Admin Key и передаёт backend-разработчику ключ вместе со ссылкой https://billing.fanlead.ru/api. По этой инструкции сервис сам создаёт и настраивает проекты, тарифы, checkout, подписки и обработчик webhook. Чужие кабинеты и проекты ему недоступны.

Общие правила API

Базовый URL: https://billing.fanlead.ru. Все запросы и ответы используют JSON в UTF-8. Для запросов с телом обязателен Content-Type: application/json. Неизвестные поля отклоняются.

КодЗначение
200 / 201Запрос выполнен / объект создан.
400Неверный JSON, поле или период.
401Ключ отсутствует, неверен или отозван.
404Объект не найден в проекте этого ключа.
409Конфликт ID, выключенный тариф или недоступное автопродление.
410Публичная checkout-ссылка отозвана или закончился срок её действия.
429Превышен лимит; повторить позже с задержкой.
5xxВременная ошибка Billing/ЮKassa; повторять безопасно только идемпотентные запросы.
{ "error": "Описание ошибки" }

Не включайте секреты и персональные данные в URL или query string. Для журналов используйте свои externalId, externalOrderId и возвращённые UUID.

Как меняется публичный контракт

https://billing.fanlead.ru/api и https://billing.fanlead.ru/api.md — две формы одного действующего контракта: обычная страница и статическая версия для backend-разработчиков и ИИ-агентов. Они должны описывать одинаковое production-поведение.

Перед каждым изменением команда Billing сверяет внутренний регламент проекта и этот публичный контракт. Изменения endpoints, полей, значений по умолчанию, валидации, ошибок, checkout, платежей, подписок, webhook или защиты документируются в обеих публичных версиях в том же релизе. Внутренний рефакторинг без влияния на сервисы не создаёт новый контракт, но также проверяется на соответствие этой документации.

Совместимость по умолчанию

Действующие интеграции не должны ломаться молча. Несовместимое изменение требует новой явной версии или описанной миграции. Примеры, отмеченные как работающие, можно использовать в сервисах; неопубликованное поведение нельзя считать доступным.

Если production, /api и /api.md расходятся, сервис не должен угадывать формат или добавлять обходную логику. Сообщите об этом как об ошибке Billing, указав endpoint, запрос, ответ и время без ключей и персональных данных.

Кто за что отвечает

СторонаЧто делает
BillingХранит тарифы и суммы, получает у покупателя email для чека, создаёт платежи и чеки, проверяет результат напрямую у ЮKassa, рассчитывает оплаченный период и следующую дату, безопасно повторяет автоплатёж, подписывает и доставляет webhook.
СервисСоздаёт checkout только со своего backend, передаёт корректный ресурс и пользователя, проверяет webhook, идемпотентно выдаёт доступ, показывает даты из Billing и даёт пользователю отключить автопродление.
ЮKassaПоказывает форму оплаты, хранит банковскую карту и выполняет списание. Billing получает только ID способа оплаты, но не номер карты и CVC.

Источник правды по сумме, состоянию и датам — Billing. Сервис не прибавляет «30 дней» и не строит своё расписание списаний.

Ключи

ADMIN KEY · fla_…

Аккаунт

Постоянный ключ для создания и настройки проектов своего аккаунта. Действует до ручной замены владельцем Billing.

SERVICE KEY · flp_…

Сервис

Используется backend сервиса для тарифов, checkout, заказов и подписок конкретного проекта.

WEBHOOK SECRET

Подпись

Используется проектом только для проверки HMAC входящих событий Billing.

Только backend

Ни один из секретов нельзя передавать в браузер, мобильное приложение, URL, Git или логи. Храните их в secret manager или серверных переменных окружения. Полные Service Key и Webhook Secret показываются только при создании или ротации.

FanLead ID используется только для входа в личный кабинет. Admin Key используется только backend сервиса для управления Project API и действует до ручной замены в кабинете.

1. Создать проект

Запрос выполняет backend аккаунта.

POST /api/v1/projects
Authorization: Bearer <ADMIN_KEY>
Content-Type: application/json

{
  "externalId": "fanlead-service",
  "name": "Сервис ФАНлид",
  "logoUrl": "https://service.fanlead.ru/logo.png",
  "webhookUrl": "https://service.fanlead.ru/api/billing/webhook",
  "returnUrl": "https://service.fanlead.ru/admin",
  "webhookPayloadVersion": 2
}

Ответ

{
  "project": {
    "id": "73398b31-…",
    "externalId": "fanlead-service",
    "name": "Сервис ФАНлид",
    "status": "active"
  },
  "credentials": {
    "serviceKey": "<SERVICE_KEY>",
    "webhookSecret": "<WEBHOOK_SECRET>"
  }
}

Поля проекта

ПолеТребование
externalIdУникальный в аккаунте, 3–64 символа: строчные латинские буквы, цифры и дефис; после создания не меняется.
name1–100 символов; показывается клиенту.
logoUrlНеобязательный публичный HTTPS URL до 2048 символов или null.
webhookUrlОбязательный публичный HTTPS endpoint до 2048 символов. При доставке редиректы и приватные IP запрещены.
returnUrlОбязательный HTTPS URL, куда клиент возвращается из Billing. Это навигация, а не подтверждение оплаты.
webhookPayloadVersionНеобязательно: 1 — прежний точный контракт (по умолчанию), 2 — расширенный контракт с tariffExternalId, amountRubles, receiptEmail и billingType. Для новых интеграций используйте 2.
  • GET /api/v1/accountАккаунт по Admin Key.
  • GET /api/v1/projectsВсе проекты аккаунта.
  • GET /api/v1/projects/:projectIdОдин проект.
  • PATCH /api/v1/projects/:projectIdНазвание, логотип, webhookUrl, returnUrl или webhookPayloadVersion.
  • POST /api/v1/projects/:projectId/rotate-service-keyВыпустить новый Service Key; предыдущий сразу перестаёт работать.
  • POST /api/v1/projects/:projectId/rotate-webhook-secretВыпустить новый Webhook Secret; новые события сразу подписываются им.
POST /api/v1/projects/<PROJECT_ID>/rotate-service-key
Authorization: Bearer <ADMIN_KEY>

{ "serviceKey": "<NEW_SERVICE_KEY>", "warning": "…" }

Для ротации Webhook Secret замените суффикс на rotate-webhook-secret; ответ содержит webhookSecret. Полное новое значение возвращается один раз. При смене секрета сначала подготовьте сервис принять новый секрет, затем выполните ротацию — переходного периода нет.

2. Создать тариф

Все суммы передаются в копейках.

POST /api/v1/tariffs
Authorization: Bearer <SERVICE_KEY>
Content-Type: application/json

{
  "externalId": "basic",
  "name": "Базовый",
  "description": "До 5 сообществ",
  "imageUrl": null,
  "pricePerMonthKopecks": 55500,
  "billingType": "subscription",
  "currency": "RUB",
  "periods": [
    { "months": 1,  "discountPercent": 0,  "autoRenewAllowed": true },
    { "months": 3,  "discountPercent": 5,  "autoRenewAllowed": true },
    { "months": 6,  "discountPercent": 10, "autoRenewAllowed": false },
    { "months": 12, "discountPercent": 15, "autoRenewAllowed": false }
  ],
  "active": true
}

Billing рассчитывает итог: цена × месяцы × (100 − скидка) / 100. Checkout и браузер не могут передать другую цену.

externalId: 2–64 символа, строчные латинские буквы, цифры и дефис. name: 1–100, description: до 500. Цена — положительное целое число копеек. billingType: subscription по умолчанию или balance_topup для разового пополнения баланса. Периодов — 1–12; months от 1 до 60 без повторов, скидка — целое число 0–99%. Если скидки нет, передайте 0. Автопродление на конкретном периоде разрешается только через autoRenewAllowed: true.

Разовое пополнение баланса

{
  "externalId": "balance-1500",
  "name": "Баланс 1 500 ₽",
  "pricePerMonthKopecks": 150000,
  "billingType": "balance_topup",
  "currency": "RUB",
  "periods": [
    { "months": 1, "discountPercent": 0, "autoRenewAllowed": false }
  ],
  "active": true
}

Для совместимости поле pricePerMonthKopecks содержит точную разовую сумму пополнения. Тип balance_topup требует один технический период: months: 1, без скидки и автопродления. Billing не добавляет «1 мес.» в описание платежа и позиции чека ЮKassa, не создаёт подписку и возвращает billingType: "balance_topup" вместе с subscriptionId: null, periodMonths: null и currentPeriodEnd: null.

  • GET /api/v1/tariffsТарифы проекта.
  • GET /api/v1/tariffs/:tariffIdОдин тариф.
  • PATCH /api/v1/tariffs/:tariffIdИзменить тариф или выключить его через active: false.

3. Создать checkout

Только backend-to-backend.

POST /api/v1/checkouts
Authorization: Bearer <SERVICE_KEY>
Idempotency-Key: <UNIQUE_REQUEST_ID>
Content-Type: application/json

{
  "tariffId": "73398b31-…",
  "periodMonths": 3,
  "selectableTariffIds": ["73398b31-…", "0de3f702-…"],
  "selectablePeriodMonths": [1, 3, 6, 12],
  "autoRenew": true,
  "paymentMethods": ["sbp", "bank_card", "other"],
  "hidePaymentMethodSelector": false,
  "reusable": true,
  "slug": "welcome-vk",
  "externalOrderId": "order-9281",
  "resource": {
    "id": "group-uuid",
    "name": "Название сообщества",
    "photoUrl": "https://service.fanlead.ru/group.png"
  },
  "display": {
    "iconUrl": "https://service.fanlead.ru/group.png",
    "autopayTermsUrl": "https://service.fanlead.ru/legal/autopay",
    "tariffLabel": "Выберите тариф",
    "periodLabel": "Выберите период",
    "lines": [
      { "text": "Название сообщества", "url": "https://vk.com/example" },
      { "text": "Дополнительная строка" }
    ]
  },
  "customer": {
    "externalId": "vk:30630505",
    "email": "buyer@example.ru"
  },
  "metadata": { "source": "admin", "internalNumber": "CRM-4815" }
}

Ответ

{
  "checkoutId": "a0c356b5-…",
  "checkoutUrl": "https://billing.fanlead.ru/pay/welcome-vk",
  "expiresAt": null
}

Idempotency-Key обязателен: уникальная строка длиной 8–128 символов. Повтор с тем же ключом возвращает тот же checkout. externalOrderId — ваш обязательный номер или идентификатор длиной 1–128 символов; он возвращается в webhook и позволяет сопоставить оплату с записью сервиса.

Короткая красивая ссылка

Необязательное поле slug создаёт адрес вида https://billing.fanlead.ru/pay/welcome-vk. Допустимо от 3 до 32 символов: только строчные латинские буквы, цифры и дефис; первый и последний символ — буква или цифра. Адрес уникален для всего Billing и после создания не переиспользуется даже после отзыва ссылки. Если адрес занят, API отвечает 409. Не передавайте в slug email, телефон, внутренние секреты или другие персональные данные.

Короткий адрес — только читаемый псевдоним публичного checkout, а не ключ доступа. Все тарифы, разрешённые периоды, сумма и автопродление по-прежнему проверяются Billing на сервере.

Если slug не передан, Billing автоматически создаёт короткий случайный код из 16 символов, например https://billing.fanlead.ru/Q7m3Kp9xV2nR8sTd. Это 96 бит случайности: код остаётся непредсказуемым, но ссылка заметно короче прежней. Уже выпущенные ссылки с кодом из 43 символов продолжают работать.

Обычная или постоянная ссылка

Без reusable создаётся обычная ссылка: она действует 24 часа и принимает только одну успешную оплату. reusable: true создаёт постоянную ссылку без даты окончания: после каждой завершённой оплаты по ней можно платить снова. У каждой попытки будет новый уникальный Billing order.id, а externalOrderId останется идентификатором самой ссылки. Проект, ресурс и покупатель у постоянной ссылки неизменны; тарифы, способы оплаты и оформление будущих платежей можно обновить защищённым PATCH-запросом.

DELETE /api/v1/checkouts/<checkoutId>
Authorization: Bearer <SERVICE_KEY>

Удаление отвечает 204 и немедленно отзывает обычную или постоянную ссылку. Операция идемпотентна; уже проведённые платежи и оплаченный доступ сохраняются.

Общие правила страницы оплаты

  • Всегда: заголовок «Оплата сервиса …», блок «Итого» с рассчитанной суммой и кнопка «Перейти к оплате».
  • Название в заголовке берётся из проекта Billing, создавшего ссылку. Поля checkout не могут его заменить.
  • display.iconUrl необязателен. Допустим только публичный HTTPS URL; без него или при ошибке загрузки будет заглушка.
  • Если сервис предлагает автопродление, передайте HTTPS-ссылку display.autopayTermsUrl на оферту или условия автоплатежей. Billing покажет её рядом с согласием покупателя.
  • display.tariffLabel и display.periodLabel необязательны: можно передать любой обычный текст от 1 до 80 символов, включая кириллицу, знаки и эмодзи. Если поле не передано или равно null, соответствующей подписи не будет. Billing не придумывает её автоматически и показывает значение только как текст, без исполнения HTML или CSS.
  • display.lines необязателен: от нуля до двух строк, text — 1–160 символов. Для каждой строки можно передать необязательный HTTPS url; ссылка отображается синим и открывается в новой вкладке.
  • Если display не передан или передан как {}, страница показывает только заглушку без дополнительных строк.
  • Выбор тарифа показывается только при двух и более разрешённых тарифах; выбор периода — только при двух и более разрешённых периодах. Без списков используется один заданный тариф и период.
  • Строка «от … ₽ в месяц» показывается в карточке тарифа только тогда, когда хотя бы у одного его периода есть autoRenewAllowed: true. Для разовых тарифов, у которых все периоды имеют autoRenewAllowed: false, Billing скрывает эту строку автоматически.
  • Скидка берётся из тарифа и показывается только когда больше нуля. Checkout не принимает свою цену или скидку.
  • Перед оплатой Billing запрашивает email для чека. Если адрес уже известен, передайте необязательный customer.email: он хранится зашифрованным, а публичный checkout показывает только маску вида n***d@y***x.ru. Кнопка «Изменить» очищает поле и позволяет покупателю ввести другой адрес.
  • autoRenew: true заранее включает галочку автоплатежа. Если поле не передано или равно false, галочка снята. Опция доступна только при глобально включённом автоплатеже и autoRenewAllowed: true у выбранного периода.

tariffId и periodMonths задают выбор по умолчанию. Необязательные selectableTariffIds и selectablePeriodMonths разрешают покупателю выбрать один из перечисленных тарифов и периодов на странице Billing; в каждом списке от 1 до 12 уникальных значений, и значение по умолчанию обязано входить в список. Если списки не переданы, checkout остаётся фиксированным на одном тарифе и периоде.

В одном checkout нельзя смешивать тарифы subscription и balance_topup. Billing фиксирует тип внутри, но не расширяет прежний ответ создания checkout: в нём остаются только checkoutId, checkoutUrl и expiresAt, поэтому строгие существующие интеграции продолжают работать. Если тип тарифа позднее изменён, создайте новую ссылку checkout.

Способ оплаты

Необязательный список paymentMethods определяет, какие способы разрешены для этой ссылки. В нём от одного до трёх уникальных значений:

  • sbp — СБП;
  • bank_card — банковсая карта;
  • other — SberPay, T-Pay, ЮMoney и другие способы на стороне ЮKassa.

Без paymentMethods доступны все три варианта. Billing всегда показывает включённые способы в порядке: СБП, банковская карта, «Ещё способы».

hidePaymentMethodSelector: true можно передать только вместе с одним способом. Тогда Billing не показывает избыточный выбор. При двух или трёх способах выбор обязателен, а запрос скрыть его получит 400.

Billing проверяет выбр по разрешённому списку на сервере, поэтому браузер не может включить или подменить запрещённый способ. Для СБП и карты Billing передаёт в ЮKassa payment_method_data.type. Официальные инструкции ЮKassa: СБП и банковская карта.

Ссылка «Выйти из оплаты» возвращает покупателя в Billing, но сама по себе не доказывает отмену: платёж ЮKassa может остаться pending. Пока в URL есть orderId этого заказа, Billing восстанавливает тариф, период, email для чека, автопродление и способ оплаты, блокирует их изменение и показывает «Продолжить оплату» для того же платежа ЮKassa. Для постоянной reusable-ссылки открытие чистого URL без orderId начинает новую попытку в браузере: при загрузке заказ ещё не создаётся, а следующее нажатие на оплату заменяет старую неоплаченную pending-попытку новым заказом Billing. Повторное нажатие внутри той же попытки остаётся идемпотентным. Одноразовые ссылки сохраняют прежнее поведение с одним заказом.

Если публичный checkout отозван или истёк, GET /api/billing/public/<token-or-slug> возвращает 410 и безопасный объект recovery.service с названием сервиса, настроенным HTTPS adminUrl и подписью кнопки. Страница показывает понятную заглушку и предлагает вернуться в сервис, но никогда не активирует отозванную ссылку повторно.

Если активная ссылка содержит отсутствующий, устаревший или принадлежащий другой ссылке orderId, страница показывает «Эта попытка оплаты недоступна». У активной постоянной reusable-ссылки кнопка «Начать новую оплату» удаляет из адреса только параметр orderId и открывает чистый checkout. Предыдущий заказ, его платёжная и аудиторская история не удаляются; новый заказ создаётся только после нажатия кнопки оплаты. Кнопка возврата всегда использует настроенный HTTPS returnUrl проекта.

Цена защищена от подмены

Сервис передаёт только ID уже созданных тарифов. Название, цена, скидка и доступность автопродления загружаются из каталога Billing. Браузер отправляет выбранные tariffId и месяцы, но API повторно проверяет их по разрешённым спискам, принадлежность тарифов проекту и рассчитывает сумму на сервере. Поля для передачи своей цены в checkout или запросе оплаты нет.

resource.id и customer.externalId — обязательные стабильные ID из базы сервиса, до 128 символов. resource.name — обязательное техническое название до 160 символов; resource.photoUrl — необязательный публичный HTTPS URL для страницы оплаты. Внешний вид страницы задаётся только через display. metadata — плоский JSON-объект для дополнительных внутренних значений: например, второго номера заказа, CRM ID или источника. Допустимы строки до 500 символов, числа, boolean и null; секреты туда не кладите. Metadata не показывается покупателю, доступна через отдельный защищённый endpoint контекста и намеренно не добавляется в webhook. Для основного номера используйте externalOrderId.

Billing вставляет текст через textContent, принимает для ссылок и иконки только HTTPS и не принимает HTML или CSS. Название проекта, цена, скидка и разрешение автопродления всегда читаются из серверной базы Billing.

Изменить постоянную ссылку

Способы оплаты настраиваются для конкретной checkout-ссылки, а не сразу для всего сервиса. У одноразовой ссылки (reusable: false) они задаются только при создании следующего checkout через POST; менять такую ссылку нельзя. Для общей постоянной ссылки используйте тот же checkoutId:

PATCH /api/v1/checkouts/<checkoutId>
Authorization: Bearer <SERVICE_KEY>
Content-Type: application/json

{
  "tariffId": "73398b31-…",
  "selectableTariffIds": ["73398b31-…", "0de3f702-…"],
  "paymentMethods": ["sbp", "bank_card"],
  "hidePaymentMethodSelector": false,
  "display": {
    "tariffLabel": "Выберите вариант оплаты",
    "periodLabel": null,
    "lines": [
      { "text": "Новые условия", "url": "https://service.fanlead.ru/terms" }
    ]
  },
  "customer": { "email": "buyer@example.ru" },
  "resource": { "name": "Новое название города" },
  "metadata": {
    "source": "wow-admin",
    "cityId": 71,
    "paymentId": "71"
  }
}

Все поля необязательны, но нужно передать хотя бы одно. Поля display объединяются с текущими: пропущенное поле сохраняется, null очищает iconUrl, autopayTermsUrl, tariffLabel или periodLabel, а lines: [] удаляет обе строки. Если новый тариф по умолчанию ещё не разрешён, передайте tariffId и новый selectableTariffIds одним запросом. metadata целиком заменяет прежний объект; {} очищает его.

customer.email задаёт зашифрованный email чека для будущих оплат. Чтобы очистить его, отправьте { "customer": { "email": null } }. Защищённый ответ PATCH и история содержат только customer.emailConfigured. При изменении в changedFields появляется customer.email, но адрес туда не попадает. Повтор с тем же email — безопасный no-op без повторного шифрования и новой записи истории. Публичный checkout получает только маску; полный адрес используется сервером, пока покупатель не нажмёт «Изменить» и не введёт новый.

resource.name меняет отображаемое техническое название ресурса. resource.id, externalOrderId, customer.externalId, slug и URL checkout неизменяемы: при смене любого ID создайте новую ссылку.

Billing проверяет итоговую конфигурацию целиком: тарифы должны быть активны, принадлежать этому проекту, сохранять исходный тип checkout и поддерживать разрешённый период; тариф по умолчанию обязан входить в список. Скрыть выбор способов по-прежнему можно только при одном разрешённом способе.

URL, checkoutId, externalOrderId, resourceId, customer.externalId и короткий адрес не меняются. Новые настройки применяются только к оплатам, созданным после успешного PATCH. Уже созданные заказы, email чеков, снимки metadata, платежи ЮKassa, подписки и поставленные в очередь или отправленные webhook не переписываются. Например, удаление bank_card запрещает карту для следующей оплаты по общей ссылке, но не отменяет уже созданный карточный платёж с собственным orderId.

Повтор без фактических изменений безопасен: ответ содержит updated: false, и новая запись истории не создаётся. Каждое реальное изменение сохраняется атомарно с состояниями до и после. Последние 100 записей доступны только по Service Key:

GET /api/v1/checkouts/<checkoutId>/history
Authorization: Bearer <SERVICE_KEY>

История содержит только изменяемые настройки checkout. Для email сохраняются только признак emailConfigured и имя поля customer.email; адрес, токен ссылки, Service Key и Webhook Secret туда не попадают.

4. Проверить заказ

Дополнительная server-to-server проверка.

GET /api/v1/orders/:orderId
Authorization: Bearer <SERVICE_KEY>

{
  "order": {
    "id": "8d454f0c-…",
    "externalOrderId": "order-9281",
    "status": "succeeded",
    "tariffId": "0de3f702-…",
    "tariffExternalId": "pro-3m",
    "billingType": "subscription",
    "amountKopecks": 158175,
    "amountRubles": "1581.75",
    "currency": "RUB",
    "periodMonths": 3,
    "currentPeriodStart": "2026-08-22T10:00:00.000Z",
    "currentPeriodEnd": "2026-11-22T10:00:00.000Z",
    "subscriptionId": "98163a70-…",
    "paidAt": "2026-08-22T10:00:00.000Z",
    "receiptEmail": "buyer@example.ru"
  },
  "subscription": { "autoRenew": true, "nextChargeAt": "2026-11-22T10:00:00.000Z" }
}

Расширенные поля tariffExternalId, billingType, amountRubles и receiptEmail присутствуют в защищённом ответе заказа только у проекта с webhookPayloadVersion: 2. Версия 1 сохраняет прежний точный формат.

Статусы: pending, succeeded, canceled, failed. Возврат пользователя по returnUrl не подтверждает оплату. Для постоянной ссылки запрос по externalOrderId возвращает последний созданный заказ; конкретный платёж всегда запрашивайте по уникальному Billing orderId из webhook.

pending означает, что ЮKassa ещё ждёт действия покупателя или завершает подтверждение; этот статус не означает списание денег. После возврата с формы Billing дополнительно сверяет состояние с ЮKassa. Доступ выдаётся только после succeeded или валидного подписанного webhook.

Основной сигнал — проверенный webhook. Если ответ webhook потерялся или обработчик восстанавливается после сбоя, запросите заказ по UUID orderId и сверяйте его со своим externalOrderId.

Для balance_topup объект subscription равен null; у заказа billingType равен balance_topup, а periodMonths, currentPeriodStart, currentPeriodEnd и subscriptionId равны null. Начисляйте баланс ровно один раз по подписанному событию payment.succeeded, сохраняя eventId под UNIQUE-ограничением.

Если внутренний UUID ещё неизвестен, используйте GET /api/v1/orders?externalOrderId=order-9281 с Service Key. Ответ имеет тот же формат. Это основной способ периодической reconciliation по заказам сервиса.

Получить дополнительные параметры

GET /api/v1/orders/<orderId>/context
Authorization: Bearer <SERVICE_KEY>

{
  "orderId": "8d454f0c-…",
  "externalOrderId": "order-9281",
  "metadata": { "source": "admin", "internalNumber": "CRM-4815" }
}

Endpoint отделён от основного ответа заказа, чтобы не менять строгий webhook-контракт и не раскрывать контекст без Service Key. Metadata копируется в заказ в момент его создания: последующий PATCH постоянной ссылки влияет только на будущие заказы, а для старого orderId возвращается исходный снимок.

5. Автопродление

Billing хранит расписание и зашифрованный ID способа оплаты.

GET /api/v1/subscriptions/:subscriptionId
Автоплатежи подключены в боевом магазине ЮKassa

Billing формирует чек на каждую первую и повторную оплату: одна услуга, полный расчёт, без НДС (vat_code: 1). После явного согласия покупателя первый платёж создаётся с save_payment_method=true. Billing хранит только зашифрованный payment_method_id, а номер карты и CVC остаются у ЮKassa. Email хранится зашифрованным; публичный checkout получает только маску, а полный итоговый адрес возвращается через защищённый Service Key API заказа и подписанные успешные webhook.

Billing создаёт запись подписки для каждого успешно оплаченного тарифа с billingType: "subscription", даже если пользователь не включил автопродление. Такое событие payment.succeeded содержит UUID subscriptionId. Оплата balance_topup подпиской не является: запись подписки и период доступа для неё не создаются.

Authorization: Bearer <SERVICE_KEY>

{
  "subscription": {
    "id": "98163a70-…",
    "status": "active",
    "autoRenew": true,
    "cancelAtPeriodEnd": false,
    "periodMonths": 3,
    "currentPeriodStart": "2026-08-22T10:00:00.000Z",
    "currentPeriodEnd": "2026-11-22T10:00:00.000Z",
    "nextChargeAt": "2026-11-22T10:00:00.000Z",
    "nextRetryAt": null,
    "paymentMethodSaved": true
  }
}

Отмена пользователем и отвязка способа оплаты

PATCH /api/v1/subscriptions/:subscriptionId
Authorization: Bearer <SERVICE_KEY>
Content-Type: application/json

{ "autoRenew": false }

Это обязательная операция отмены из пользовательского интерфейса. После успешного ответа autoRenew=false, cancelAtPeriodEnd=true, nextChargeAt=null, а paymentMethodSaved=false: Billing удаляет сохранённый идентификатор способа оплаты ЮKassa из своей базы. Оплаченный доступ остаётся до currentPeriodEnd.

У ЮKassa нет отдельной операции удаления сохранённого способа оплаты на её стороне. Отвязка означает, что Billing прекращает использовать идентификатор и удаляет его локально. После отмены запрос { "autoRenew": true } не восстановит отвязанный способ. Для новой подписки сервис создаёт новый checkout, а пользователь снова выполняет первый платёж с галочкой автоплатежа.

Обязательный интерфейс в каждом сервисе

Отмена без обращения в поддержку

Каждый сервис, который предлагает автоплатёж, обязан дать авторизованному покупателю видимую кнопку «Отключить автопродление» или «Отменить подписку».

Владелец сервиса также может отключить автопродление в разделе «Подписки» кабинета Billing. Это резервный административный способ: он не заменяет обязательную кнопку самостоятельной отмены в интерфейсе сервиса.

Чек-лист сервиса перед запуском автоплатежей

  1. Опубликуйте оферту или условия: сумма и периодичность списаний, момент первого и следующих платежей, способ отключения и срок сохранения оплаченного доступа. Передавайте ссылку через display.autopayTermsUrl.
  2. Разрешайте автопродление только у подходящих периодов тарифа через autoRenewAllowed: true. Поле checkout autoRenew задаёт лишь начальное состояние; окончательный выбор делает покупатель на Billing.
  3. Создавайте checkout только с backend сервиса. Service Key нельзя передавать в браузер, мобильное приложение, URL или логи.
  4. Проверяйте подпись webhook по исходному телу, timestamp и Webhook Secret. Храните eventId с UNIQUE-ограничением и отвечайте 2xx только после атомарной записи события.
  5. Сохраните subscriptionId и currentPeriodEnd из подписанного webhook. При потере webhook восстановите состояние запросом заказа по externalOrderId.
  6. В личном кабинете покупателя показывайте статус, currentPeriodEnd, nextChargeAt, сумму и видимую кнопку отмены.
  7. Кнопка вызывает backend сервиса. Backend проверяет владельца подписки и отправляет в Billing PATCH с { "autoRenew": false }.
  8. Показывайте успешную отмену только после ответа Billing 200 и значений autoRenew=false, nextChargeAt=null, paymentMethodSaved=false. Сообщите, что доступ сохранится до currentPeriodEnd.
  9. Не запускайте собственное расписание списаний: Billing рассчитывает дату, создаёт повторный платёж и выполняет ограниченные повторы. Сервис только применяет подписанные события.
  10. Перед production проведите контрольную оплату, убедитесь в paymentMethodSaved=true, проверьте экран подписки и выполните реальную отмену из интерфейса сервиса.

Отмена идемпотентна: повтор для уже отвязанной активной подписки возвращает то же безопасное состояние. Если повторный платёж уже обрабатывается, Billing отвечает 409; backend сервиса повторяет отмену через короткий интервал и не показывает успех до ответа 200. Так подтверждённая отмена не пересекается со списанием, уже отправляемым в ЮKassa.

При новой отмене Billing также отправляет подписанный webhook subscription.canceled. Вариант «напишите в поддержку» требованиям ЮKassa не соответствует.

При неуспешном списании Billing заполняет nextRetryAt и делает до трёх безопасных повторов. Если ЮKassa вернула permission_revoked, Billing сразу прекращает повторы, отключает автопродление и удаляет сохранённый идентификатор. Сервис показывает состояние как есть и не запускает своё списание.

Автопродление возможно только если одновременно: оно глобально включено в Billing, период тарифа содержит autoRenewAllowed: true, а галочка включена на платёжной странице. Запрос checkout может задать её начальное состояние через autoRenew; пользователь видит состояние и может изменить его до оплаты.

6. Принять webhook

Billing ждёт ответ, повторяет доставку и поддерживает отдельное ACK.

POST {project.webhookUrl}

Адрес задаётся в webhookUrl при создании или настройке проекта. Код 2xx возвращает ваш сервис после атомарной записи eventId и изменения доступа; Billing не формирует этот ответ за сервис.

Первая доставка выполняется сразу. Каждое HTTP-соединение Billing держит открытым до 5 минут. Если первый ответ не пришёл, вторая попытка начинается примерно через 5 минут. Следующие начала попыток — через 10, 20, 40 и 60 минут от начала предыдущей, затем каждый час. После закрытия пятиминутного соединения Billing ждёт остаток интервала без новой отправки. Redirect, ошибка или не-2xx считаются неуспехом.

Долгая обработка или потерянный ответ

Лучше быстро сохранить событие и вернуть 2xx, а тяжёлую работу выполнить в фоне. Если соединение уже закрылось, после успешной обработки вызовите POST /api/v1/webhook-events/<eventId>/ack с Service Key. Endpoint идемпотентен, возвращает 204 и останавливает повторы. Ответ на уже закрытое HTTP-соединение Billing увидеть не может.

POST /api/v1/webhook-events/6872274e-…/ack
Authorization: Bearer <SERVICE_KEY>

204 No Content

В Billing Admin доступны платежи, параметры заказа, тело webhook, каждая попытка, HTTP-статус, короткий ответ сервиса и время следующей попытки. Секреты и HMAC-заголовки не сохраняются.

Ниже показан контракт webhook v2. Включите его при создании или обновлении проекта: webhookPayloadVersion: 2. Версия 1 используется по умолчанию для обратной совместимости и не содержит tariffExternalId, amountRubles, receiptEmail и billingType. Версия сохраняется в самом событии, поэтому её последующая смена не меняет тело уже повторяемого webhook. currentPeriodStart доступен через endpoint подписки и намеренно не отправляется; для выдачи или продления доступа используйте currentPeriodEnd.

X-FanLead-Event-Id: 6872274e-…
X-FanLead-Timestamp: 1787392800
X-FanLead-Signature: v1=<HMAC_SHA256_HEX>

{
  "eventId": "6872274e-…",
  "type": "payment.succeeded",
  "createdAt": "2026-08-22T10:00:00.000Z",
  "projectId": "73398b31-…",
  "data": {
    "orderId": "8d454f0c-…",
    "externalOrderId": "order-9281",
    "tariffId": "0de3f702-…",
    "tariffExternalId": "pro-3m",
    "periodMonths": 3,
    "amountKopecks": 158175,
    "amountRubles": "1581.75",
    "currency": "RUB",
    "autoRenew": true,
    "subscriptionId": "98163a70-…",
    "resourceId": "group-uuid",
    "customerExternalId": "vk:30630505",
    "currentPeriodEnd": "2026-11-22T10:00:00.000Z",
    "nextChargeAt": "2026-11-22T10:00:00.000Z",
    "paidAt": "2026-08-22T10:00:00.000Z",
    "receiptEmail": "buyer@example.ru",
    "providerPaymentId": "2f6f3ec4-…"
  }
}

События: payment.succeeded, subscription.renewed, subscription.payment_failed, subscription.canceled.

В v2 amountKopecks остаётся канонической целой суммой для расчётов. amountRubles содержит ту же сумму строкой ровно с двумя знаками после точки. tariffExternalId — заданный сервисом идентификатор тарифа, например balance-1500.

В v2 итоговый receiptEmail приходит только в событиях payment.succeeded и subscription.renewed. Billing берёт адрес из фактического запроса после возможного исправления покупателем. Публичный checkout получает только маску; полный адрес доступен в подписанном webhook и защищённом Service Key API заказа, но не попадает в URL, публичный API или историю изменений.

В payment.succeeded для пополнения дополнительно приходит billingType: "balance_topup", а periodMonths, subscriptionId, currentPeriodEnd и nextChargeAt равны null.

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

signedPayload = X-FanLead-Timestamp + "." + RAW_HTTP_BODY
expected = hex(HMAC_SHA256(WEBHOOK_SECRET, signedPayload))
received = значение после "v1=" в X-FanLead-Signature
  1. 1
    Сохранить исходное тело

    HMAC считается по байтам тела до JSON parsing.

  2. 2
    Проверить timestamp

    Отклонение от текущего Unix-времени — не больше 300 секунд.

  3. 3
    Сравнить HMAC constant-time

    Обычное сравнение строк использовать нельзя.

  4. 4
    Сверить идентификаторы

    Header event ID совпадает с body.eventId, а projectId — с вашим проектом.

  5. 5
    Обработать один раз

    Сохранить eventId с UNIQUE constraint. Повтор отвечает 2xx, но доступ повторно не продлевает.

  6. 6
    Записать атомарно

    eventId и изменение доступа записываются в одной транзакции. После этого вернуть 2xx.

Обязательные проверки сервиса

  • Хранить все ключи только в server-side secrets/env.
  • Создавать checkout только с backend и отправлять уникальный Idempotency-Key.
  • Не доверять цене, статусу или дате из браузера и returnUrl.
  • Проверять raw-body HMAC, timestamp ±5 минут и подпись constant-time.
  • Хранить eventId с UNIQUE constraint и обрабатывать событие идемпотентно.
  • Сверять projectId, externalOrderId, тариф, ресурс, сумму и валюту.
  • Использовать даты Billing и не рассчитывать следующий платёж самостоятельно.
  • Не хранить номер карты, CVC или ID способа оплаты.
Одного Webhook Secret недостаточно

HMAC подтверждает отправителя. Timestamp защищает от старых запросов, UNIQUE eventId — от повторного начисления, а сверка заказа — от применения события не к тому ресурсу.

Чек-лист перед запуском

  • Создан проект, а Admin Key, Service Key и Webhook Secret сохранены раздельно.
  • Создан хотя бы один активный тариф и проверен расчёт каждого периода.
  • Повтор POST /api/v1/checkouts с тем же Idempotency-Key возвращает тот же checkout.
  • Тестовый платёж выдаёт доступ только после валидного webhook или server-to-server проверки заказа.
  • Повтор одного eventId не продлевает доступ второй раз; неверная подпись и timestamp вне допустимого окна отклоняются.
  • В интерфейсе сервиса есть самостоятельная отмена без поддержки; она вызывает Billing PATCH с backend.
  • Отмена очищает сохранённый способ оплаты и сохраняет доступ до currentPeriodEnd.
  • Секреты отсутствуют во frontend-коде, URL, Git и логах; есть процедура их ротации.