# FanLead Billing API v1

> Статическая самодостаточная документация для backend-разработчиков и ИИ-агентов.

- Base URL: `https://billing.fanlead.ru`
- Human-readable documentation: `https://billing.fanlead.ru/api`
- This Markdown document: `https://billing.fanlead.ru/api.md`
- Billing Admin: `https://billing.fanlead.ru/admin`
- Format: JSON, UTF-8
- Updated: 2026-09-18

FanLead Billing принимает оплату для сервисов ФАНлид. Сервис создаёт проект, тариф и checkout. Billing показывает публичную страницу оплаты, проверяет ЮKassa, ведёт заказ и подписку, затем отправляет подписанный webhook сервису.

## Integration sequence

1. Владелец входит в Billing Admin через FanLead ID и один раз выпускает постоянный Admin Key.
2. Backend сервиса создаёт проект с Admin Key и получает Service Key и Webhook Secret.
3. Backend создаёт тариф с Service Key.
4. Backend создаёт checkout с Service Key и уникальным `Idempotency-Key`.
5. Пользователь открывает `checkoutUrl` и оплачивает заказ.
6. Сервис проверяет HMAC webhook, timestamp, `eventId` и идентификаторы заказа.
7. Сервис атомарно сохраняет `eventId` и выдаёт доступ до `currentPeriodEnd`.

## Global rules

- Все секретные ключи используются только на backend.
- Для запросов с телом обязателен `Content-Type: application/json`.
- Неизвестные JSON-поля отклоняются.
- Секреты и персональные данные нельзя передавать в URL, query string, frontend, Git или логи.
- Источник правды по цене, статусу заказа и датам подписки — Billing.
- Сервис не рассчитывает цену или следующую дату списания самостоятельно.
- Возврат пользователя по `returnUrl` не подтверждает успешную оплату.

## Contract and change policy

`https://billing.fanlead.ru/api` and `https://billing.fanlead.ru/api.md` are two representations of the same current public contract: the first is the readable page, and the second is the static version for backend developers and AI agents. They must describe the same live behavior.

Billing maintainers check the internal project rules and this public contract before every change. A change to endpoints, fields, defaults, validation, errors, checkout behavior, payments, subscriptions, webhook or security is documented in both public representations in the same release. Internal refactoring that does not affect services does not create a new public contract, but it is still checked against this document.

Existing integrations remain compatible by default. A breaking payload change requires a new explicit version or a documented migration; it must not silently change an existing version. Examples marked as live may be used by services. Unpublished behavior must not be treated as available.

If production behavior, `/api`, and `/api.md` disagree, do not guess or compensate in the integrating service. Treat the mismatch as a Billing incident and report the exact endpoint, request, response and time without including keys or personal data.

Standard error body:

```json
{ "error": "Описание ошибки" }
```

HTTP statuses:

| Status | Meaning |
|---|---|
| `200` | Запрос выполнен |
| `201` | Объект создан |
| `400` | Неверный JSON, поле или период |
| `401` | Ключ отсутствует, неверен или отозван |
| `404` | Объект не найден в области доступа ключа |
| `409` | Конфликт ID, выключенный тариф или недоступное автопродление |
| `410` | Публичная checkout-ссылка отозвана или закончился срок её действия |
| `429` | Превышен лимит; повторить позже с задержкой |
| `5xx` | Временная ошибка Billing или ЮKassa; повторять только идемпотентные запросы |

## Responsibilities

### Billing

- Хранит тарифы и рассчитывает итоговую сумму.
- Получает у покупателя email, создаёт платёж и фискальный чек, затем проверяет платёж напрямую у ЮKassa.
- Рассчитывает оплаченный период и следующую дату.
- Безопасно повторяет автоплатёж.
- Подписывает и доставляет webhook.

### Integrating service

- Создаёт checkout только со своего backend.
- Передаёт стабильные ID ресурса, пользователя и заказа.
- Проверяет webhook до выдачи доступа.
- Обрабатывает события идемпотентно.
- Показывает даты Billing без самостоятельного пересчёта.
- Даёт пользователю отключить автопродление.

### YooKassa

- Показывает платёжную форму.
- Хранит банковскую карту и выполняет списание.
- Billing не получает номер карты или CVC.

## Authentication keys

| Key | Prefix | Scope | Lifetime |
|---|---|---|---|
| Admin Key | `fla_…` | Аккаунт и его проекты | Постоянный до ручной ротации |
| Service Key | `flp_…` | Один проект: тарифы, checkout, заказы, подписки | До ручной ротации |
| Webhook Secret | — | Проверка HMAC событий одного проекта | До ручной ротации |

Use Admin Key or Service Key as a Bearer token:

```http
Authorization: Bearer <ADMIN_KEY_OR_SERVICE_KEY>
```

Полные Service Key и Webhook Secret возвращаются только при создании или ротации. Старый ключ сразу перестаёт работать после ротации.

FanLead ID используется только для входа владельца в Billing Admin. Он не заменяет API-ключи.

## 1. Account and project

### Create project

```http
POST /api/v1/projects
Authorization: Bearer <ADMIN_KEY>
Content-Type: application/json
```

```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
}
```

Response:

```json
{
  "project": {
    "id": "73398b31-…",
    "externalId": "fanlead-service",
    "name": "Сервис ФАНлид",
    "status": "active"
  },
  "credentials": {
    "serviceKey": "<SERVICE_KEY>",
    "webhookSecret": "<WEBHOOK_SECRET>"
  }
}
```

Project fields:

| Field | Requirement |
|---|---|
| `externalId` | Уникальный в аккаунте; 3–64 символа: `[a-z0-9-]`; после создания не меняется |
| `name` | 1–100 символов; показывается клиенту |
| `logoUrl` | Необязательный публичный HTTPS URL до 2048 символов или `null` |
| `webhookUrl` | Обязательный публичный HTTPS endpoint до 2048 символов; redirect и private/loopback IP запрещены |
| `returnUrl` | Обязательный HTTPS URL для навигации пользователя после Billing |
| `webhookPayloadVersion` | Необязательно: `1` (совместимый старый контракт, значение по умолчанию) или `2` (добавляет `tariffExternalId`, `amountRubles`, `receiptEmail` и `billingType`) |

Other account/project endpoints:

```text
GET   /api/v1/account
GET   /api/v1/projects
GET   /api/v1/projects/:projectId
PATCH /api/v1/projects/:projectId
POST  /api/v1/projects/:projectId/rotate-service-key
POST  /api/v1/projects/:projectId/rotate-webhook-secret
```

Все эти endpoints используют Admin Key. `PATCH` изменяет название, логотип, `webhookUrl`, `returnUrl` или `webhookPayloadVersion`. Для новой интеграции укажите версию `2`; версия `1` сохранена для уже работающих сервисов со строгой JSON-схемой.

Service Key rotation response:

```json
{ "serviceKey": "<NEW_SERVICE_KEY>", "warning": "…" }
```

Webhook Secret rotation response содержит `webhookSecret`. Переходного периода нет: новые события сразу подписываются новым секретом.

## 2. Tariffs

### Create tariff

```http
POST /api/v1/tariffs
Authorization: Bearer <SERVICE_KEY>
Content-Type: application/json
```

```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 calculates:

```text
amount = pricePerMonthKopecks × months × (100 − discountPercent) / 100
```

The browser and checkout request cannot override the amount.

Tariff requirements:

- `externalId`: 2–64 characters, `[a-z0-9-]`.
- `name`: 1–100 characters.
- `description`: up to 500 characters.
- `pricePerMonthKopecks`: positive integer kopecks.
- `billingType`: `subscription` by default, or `balance_topup` for a one-time balance purchase.
- `currency`: `RUB`.
- `periods`: 1–12 unique periods.
- `months`: integer from 1 to 60.
- `discountPercent`: integer from 0 to 99; use `0` when there is no discount.
- Autopay is allowed for a period only when `autoRenewAllowed` is `true`.

For a one-time balance purchase, create the tariff as follows:

```json
{
  "externalId": "balance-1500",
  "name": "Баланс 1 500 ₽",
  "pricePerMonthKopecks": 150000,
  "billingType": "balance_topup",
  "currency": "RUB",
  "periods": [
    { "months": 1, "discountPercent": 0, "autoRenewAllowed": false }
  ],
  "active": true
}
```

For compatibility, `pricePerMonthKopecks` is the exact one-time top-up amount when `billingType` is `balance_topup`. This type requires exactly one technical period (`months: 1`, no discount, no autopay). Billing does not add `1 мес.` to the YooKassa payment description or receipt item, does not create a subscription, and sends `billingType: "balance_topup"` with `subscriptionId: null`, `periodMonths: null`, and `currentPeriodEnd: null` in the order and webhook.

Other tariff endpoints:

```text
GET   /api/v1/tariffs
GET   /api/v1/tariffs/:tariffId
PATCH /api/v1/tariffs/:tariffId
```

Set `active: false` through `PATCH` to disable a tariff.

## 3. Checkout

Checkout creation is backend-to-backend only.

```http
POST /api/v1/checkouts
Authorization: Bearer <SERVICE_KEY>
Idempotency-Key: <UNIQUE_REQUEST_ID>
Content-Type: application/json
```

```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"
  }
}
```

Response:

```json
{
  "checkoutId": "a0c356b5-…",
  "checkoutUrl": "https://billing.fanlead.ru/pay/welcome-vk",
  "expiresAt": null
}
```

Поля `tariffExternalId`, `billingType`, `amountRubles` и `receiptEmail` присутствуют в защищённом ответе заказа только при `webhookPayloadVersion: 2`. Версия `1` сохраняет прежний точный формат ответа.

Checkout rules:

- The page always contains the heading `Оплата сервиса <project name>`, `Итого`, the server-calculated amount and the pay button.
- The heading name always comes from the Billing project that created the link. The checkout request cannot override it.
- `Idempotency-Key` is required and contains 8–128 characters.
- Repeating the same request with the same `Idempotency-Key` returns the same checkout.
- Optional `slug` creates a readable URL such as `https://billing.fanlead.ru/pay/welcome-vk`. It contains 3–32 lowercase Latin letters, digits or hyphens and must start and end with a letter or digit.
- A slug is globally unique and is never reassigned, including after checkout revocation. Billing returns `409` if it is occupied. Never put email, phone numbers, secrets or other personal data into it.
- A short URL is a public alias, not an authentication credential. Billing still validates tariff ownership, the allowed periods, price, discount and autopay server-side.
- If `slug` is omitted, Billing generates a 16-character random code, for example `https://billing.fanlead.ru/Q7m3Kp9xV2nR8sTd`. It has 96 bits of randomness. Existing 43-character checkout URLs remain valid.
- `externalOrderId` is your required order or checkout reference, contains 1–128 characters, and cannot be reused to create another checkout. Billing returns it in the webhook so the service can correlate the payment.
- By default `reusable` is `false`: the link is valid for 24 hours and accepts one successful payment.
- `reusable: true` creates a permanent link without an expiration date. It accepts sequential payments without limit. Every attempt has a new unique Billing `order.id`; `externalOrderId` remains the permanent-link reference. The project, resource and customer stay fixed. Tariffs, payment methods and display fields can be updated for future payments through the protected PATCH endpoint below.
- `tariffId` and `periodMonths` are the default choice.
- Optional `selectableTariffIds` and `selectablePeriodMonths` let the buyer choose on the Billing page. Each list contains 1–12 unique values and must include its default value.
- The tariff selector is rendered only for two or more allowed tariffs. The period selector is rendered only for two or more allowed periods. If the lists are omitted, the checkout is fixed to its defaults.
- Tariff cards show `от … ₽ в месяц` only when at least one period of that tariff has `autoRenewAllowed: true`. For one-time tariffs where every period has `autoRenewAllowed: false`, Billing hides this monthly caption automatically.
- A Service Key can select only active tariffs belonging to its own project. Every selectable tariff must support at least one selectable period.
- One checkout cannot mix `subscription` and `balance_topup` tariffs. Billing snapshots the type internally without expanding the established checkout response (`checkoutId`, `checkoutUrl`, `expiresAt`), so strict existing integrations remain compatible. If the tariff type is later changed, create a new checkout link.
- The service sends tariff IDs only. Billing reads tariff names, prices, discounts and autopay availability from its server-side catalog.
- The browser submits the chosen `tariffId` and months, but Billing revalidates both against the checkout allowlist and recalculates the amount server-side. There is no client-controlled amount field.
- Optional `paymentMethods` is the allowlist for this checkout. It contains 1–3 unique values: `sbp`, `bank_card`, `other`. If omitted, all three are available. Billing always displays enabled methods in the safe order SBP, bank card, then `Ещё способы`.
- Optional `hidePaymentMethodSelector: true` is accepted only when `paymentMethods` contains exactly one value. Billing then hides the redundant selector and uses that method. With two or three enabled methods the selector is mandatory and attempting to hide it returns `400`.
- Billing validates the chosen method against the checkout allowlist on the server. The browser cannot enable or substitute a forbidden method.
- Billing sends the selected method to YooKassa as `payment_method_data.type`. See the official YooKassa instructions for [SBP](https://yookassa.ru/developers/payment-acceptance/integration-scenarios/manual-integration/other/sbp) and [bank cards](https://yookassa.ru/developers/payment-acceptance/integration-scenarios/manual-integration/bank-card).
- Returning through YooKassa's “Выйти из оплаты” link does not itself prove cancellation: the provider payment may remain `pending`. While the URL still contains that order's `orderId`, Billing restores and locks its tariff, period, receipt email, autopay flag and payment method and shows “Продолжить оплату” for the same YooKassa payment. On a permanent `reusable` link, opening the clean URL without `orderId` starts a fresh browser attempt: no order is created on page load, and the next press on the payment button replaces an unpaid old `pending` attempt with a new Billing order. A repeated press inside the same browser attempt remains idempotent. One-time links keep their existing single-order behavior.
- If a public checkout was revoked or expired, `GET /api/billing/public/<token-or-slug>` returns `410` with a safe `recovery.service` object containing the configured service title, HTTPS `adminUrl` and return-button label. The payment page shows a recovery screen instead of a technical error and lets the buyer return to the service. It never reactivates a revoked checkout.
- If an active checkout URL contains a missing, stale or foreign `orderId`, the page shows “Эта попытка оплаты недоступна”. For an active permanent `reusable` link it offers “Начать новую оплату”, removes only the `orderId` query parameter and reloads the clean checkout. Billing does not delete the old order or its audit/payment history; the new order is created only after the buyer presses the payment button. A return-to-service action always uses the project's configured HTTPS `returnUrl`.
- `resource.id` and `customer.externalId` are stable service database IDs up to 128 characters.
- `resource.name` is up to 160 characters.
- `resource.photoUrl` is an optional public HTTPS URL used on the checkout page. It does not control the new checkout presentation.
- Optional `display.iconUrl` is the checkout icon and must be a public HTTPS URL. If it is omitted or fails to load, Billing shows a placeholder with the first service letter.
- A service that offers autopay must send `display.autopayTermsUrl`, an HTTPS link to its offer or autopay terms. Billing renders the link next to the buyer's consent.
- Optional `display.tariffLabel` and `display.periodLabel` contain any plain text of 1–80 characters (including Cyrillic, punctuation and emoji) and control the captions above the tariff and period selectors. Omit a field or send `null` to hide that caption. Billing does not invent either caption automatically and renders the value only as text, never as HTML or CSS.
- Optional `display.lines` contains zero to two rows. Each row requires `text` of 1–160 characters and may contain an HTTPS `url`; linked rows are blue and open in a new tab.
- Omit `display` or send `{}` to render only the placeholder with no additional rows.
- `metadata` is a flat JSON object for extra internal values such as a second order number, CRM ID or source. Values may be strings up to 500 characters, numbers, booleans or null. Do not put secrets into it.
- `metadata` is not rendered on the checkout page and is not added to the strict webhook payload. Read it through the separate protected order-context endpoint. Use `externalOrderId` for the primary service order number.
- Discounts come from the Billing tariff catalog and are displayed only when greater than zero. The checkout cannot submit its own price or discount.
- `autoRenew: true` preselects the autopay checkbox. If omitted or `false`, the checkbox is unchecked. It is available only when Billing autopay is globally enabled and the default period has `autoRenewAllowed: true`; otherwise a `true` request is rejected.
- The user sees the checkbox state and can change it before payment.
- Billing renders display text via `textContent`, accepts only HTTPS display URLs, and accepts no checkout HTML or CSS.
- Before payment, Billing requires an email for the fiscal receipt. If it is already known, send optional `customer.email`; Billing stores it encrypted and shows only a mask such as `n***d@y***x.ru` in the public checkout. The buyer can keep the saved address or press `Изменить` and enter another one for this payment. The public API returns only `receiptEmailConfigured` and `receiptEmailMasked`, never the full address. Contract v1 omits the final address; contract v2 returns it through the Service-Key-protected order API and signed successful webhooks.

### Update a permanent checkout

Payment-method settings belong to one checkout link, not to the whole service. For a one-time link (`reusable: false`) set them only when creating the next checkout with `POST /api/v1/checkouts`. A one-time checkout cannot be patched.

For a shared permanent link, update the same URL without changing `externalOrderId`, `resourceId`, `customer.externalId` or the slug:

```http
PATCH /api/v1/checkouts/<checkoutId>
Authorization: Bearer <SERVICE_KEY>
Content-Type: application/json
```

```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"
  }
}
```

Every field is optional, but the body must contain at least one field. `display` is merged field-by-field: omit a display field to keep it, use `null` to clear `iconUrl`, `autopayTermsUrl`, `tariffLabel` or `periodLabel`, and use `lines: []` to clear both rows. To change the default to a tariff that was not previously allowed, send both `tariffId` and the new `selectableTariffIds` in the same request. `metadata` replaces the complete metadata object; send `{}` to clear it.

`customer.email` sets the encrypted receipt email for future payments. Send `{ "customer": { "email": null } }` to clear it. The protected PATCH response and change history expose only `customer.emailConfigured`; a real email change adds `customer.email` to `changedFields`, but not the address. Repeating the same email is a no-op and does not re-encrypt it or create a history entry. The public checkout receives only a masked value and uses the encrypted address server-side unless the buyer presses `Изменить` and submits a replacement.

`resource.name` changes only the displayed technical resource name for future visits. `resource.id`, `externalOrderId`, `customer.externalId`, slug and checkout URL are immutable; create a new checkout when one of those identifiers changes.

The resulting checkout must remain valid: all tariffs must be active and owned by the same project, keep the original checkout billing type, support an already allowed period, and include the default tariff. `hidePaymentMethodSelector: true` still requires exactly one payment method.

The response contains the unchanged `checkoutId`, `checkoutUrl`, `externalOrderId` and `resourceId`, the resulting mutable settings, `updated`, and `changedFields`. Repeating an effective no-op is safe: it returns `updated: false` and does not create an audit entry.

Only payments created after the successful PATCH use the new configuration. Existing orders, receipt emails, metadata snapshots, provider payments, subscriptions and already queued or delivered webhooks are never rewritten. In particular, removing `bank_card` prevents it for the next payment on that permanent link but does not cancel a pending card payment that already has an `orderId`.

Billing records each effective change atomically with its before/after state. Read the newest 100 entries with the same Service Key:

```http
GET /api/v1/checkouts/<checkoutId>/history
Authorization: Bearer <SERVICE_KEY>
```

The history contains only mutable checkout settings. For email it stores only `emailConfigured` and the field name `customer.email`; it never contains the address, checkout tokens, Service Keys or webhook secrets.

Revoke any ordinary or permanent link with the same Service Key:

```http
DELETE /api/v1/checkouts/<checkoutId>
Authorization: Bearer <SERVICE_KEY>
```

The endpoint returns `204`. Revocation is idempotent and does not undo completed payments or paid access.

## 4. Orders

### Get order by Billing UUID

```http
GET /api/v1/orders/:orderId
Authorization: Bearer <SERVICE_KEY>
```

### Get order by service order ID

```http
GET /api/v1/orders?externalOrderId=order-9281
Authorization: Bearer <SERVICE_KEY>
```

Response:

```json
{
  "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"
  }
}
```

For a reusable link, lookup by `externalOrderId` returns the latest created order. Always use the unique Billing `orderId` from the webhook when requesting one specific payment.

Read extra parameters without expanding the strict webhook or the existing order response:

```http
GET /api/v1/orders/<orderId>/context
Authorization: Bearer <SERVICE_KEY>
```

```json
{
  "orderId": "8d454f0c-…",
  "externalOrderId": "order-9281",
  "metadata": { "source": "admin", "internalNumber": "CRM-4815" }
}
```

`metadata` is copied into the order when the order is created. Updating a reusable checkout later affects only future orders; this endpoint continues to return the original snapshot for every existing `orderId`.

Order statuses:

```text
pending
succeeded
canceled
failed
```

`pending` means YooKassa is still waiting for buyer confirmation or finishing it; it does not prove that money was charged. After return from the provider form, Billing verifies the current provider status. Grant access only after `succeeded` or a valid signed webhook.

The signed webhook is the primary success signal. Use the order endpoints for server-to-server verification and reconciliation after lost or delayed webhooks.

For `balance_topup`, `subscription` is `null`, while `order.billingType` is `balance_topup`; `periodMonths`, `currentPeriodStart`, `currentPeriodEnd`, and `subscriptionId` are `null`. Credit the balance exactly once using the signed `payment.succeeded` event and a UNIQUE constraint on `eventId`.

## 5. Subscriptions and autopay

The current environment uses a live YooKassa shop with recurring bank-card payments enabled. Billing creates a receipt for every initial and recurring payment using one service item, `payment_mode: full_payment`, `payment_subject: service`, and no VAT (`vat_code: 1`). After the buyer explicitly opts in, the initial payment is created with `save_payment_method: true`. Billing stores only the encrypted YooKassa `payment_method_id`; card number and CVC remain with YooKassa. The final receipt email entered by the buyer is encrypted at rest and is returned only through the Service-Key-protected order API and signed successful webhooks; it is never placed in a URL or the public checkout-status response.

Autopay is available only when all conditions are true:

1. Autopay is globally enabled in Billing.
2. The selected tariff period has `autoRenewAllowed: true`.
3. The checkbox is enabled on the Billing checkout page. The checkout request may preselect it with `autoRenew: true`; the user can change it before payment.

Billing creates a subscription record for every successful tariff with `billingType: "subscription"`, including a one-time period with `autoRenew: false`. Such a `payment.succeeded` event contains a UUID `subscriptionId`. A `balance_topup` payment is not a subscription: no subscription record or paid-access period is created.

### Get subscription

```http
GET /api/v1/subscriptions/:subscriptionId
Authorization: Bearer <SERVICE_KEY>
```

```json
{
  "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
  }
}
```

### User cancellation and payment-method detachment

```http
PATCH /api/v1/subscriptions/:subscriptionId
Authorization: Bearer <SERVICE_KEY>
Content-Type: application/json
```

```json
{ "autoRenew": false }
```

This is the required cancellation operation for an end-user interface. A successful response means all of the following:

- `autoRenew` is `false`;
- `cancelAtPeriodEnd` is `true`;
- `nextChargeAt` is `null`;
- `paymentMethodSaved` is `false` because Billing deletes the stored YooKassa `payment_method_id` from its database;
- paid access remains available through `currentPeriodEnd`.

YooKassa does not provide a separate operation that deletes a saved payment method on its side. Detachment means that Billing permanently stops using the identifier and removes it locally. After user cancellation, `{"autoRenew": true}` cannot restore the detached method. To subscribe again, create a new checkout and let the user complete a new initial payment with the autopay checkbox.

### Mandatory interface in every service

Every service that offers autopay must provide an authenticated subscription screen where the buyer can cancel without contacting support:

The service owner can also cancel autopay from the **Подписки** section of Billing Admin. This is an administrative fallback and does not replace the mandatory self-service cancellation control in the connected service.

Before enabling autopay, the service must complete this checklist:

1. Publish an offer or autopay terms covering the amount, frequency, initial and recurring charge timing, cancellation method, and paid-access end date. Pass its HTTPS URL as `display.autopayTermsUrl`.
2. Set `autoRenewAllowed: true` only for eligible tariff periods. Checkout `autoRenew` only preselects the checkbox; the buyer makes the final choice on Billing.
3. Create checkout sessions only from the service backend. Never expose the Service Key in a browser, mobile application, URL, or logs.
4. Verify every webhook against the raw body, timestamp, and Webhook Secret. Persist `eventId` under a UNIQUE constraint and return `2xx` only after the event transaction commits.
5. Store `subscriptionId` and `currentPeriodEnd` from the signed webhook. Recover a lost event through the order endpoint using `externalOrderId`.
6. Show the subscription status, amount, `currentPeriodEnd`, `nextChargeAt`, and a visible cancellation button in the authenticated buyer account.
7. The browser calls the service backend. The backend verifies subscription ownership and sends `{ "autoRenew": false }` to Billing.
8. Show cancellation success only after Billing returns `200` with `autoRenew: false`, `nextChargeAt: null`, and `paymentMethodSaved: false`. Explain that access remains through `currentPeriodEnd`.
9. Do not run a second charge scheduler. Billing calculates the date, creates the recurring payment, and performs bounded retries; the service applies signed events only.
10. Before production, complete one controlled payment, verify `paymentMethodSaved: true`, check the buyer subscription screen, and cancel from the service UI.

Cancellation is idempotent: repeating it for an already detached active subscription returns the same safe state. If a recurring payment is already being processed, Billing returns `409`; retry the cancellation after a short delay and do not show success before a `200` response. This prevents a cancellation response from racing with a charge already being sent to YooKassa.

When Billing accepts a new cancellation, it also sends a signed `subscription.canceled` webhook. The service must make this operation available directly in its interface; a support-only cancellation flow is not sufficient.

After a failed recurring charge, Billing sets `nextRetryAt` and performs up to three bounded retries. If YooKassa returns `permission_revoked`, Billing immediately stops retries, disables autopay, and deletes the stored payment-method identifier. The service must not start its own charge schedule.

## 6. Outgoing webhooks

The service sets `webhookUrl` when it creates or updates the project. Billing sends an HTTP `POST` to this endpoint and keeps each connection open for up to 5 minutes. The receiving service—not Billing—must return 2xx after the transaction that stores `eventId` and changes access commits.

The first delivery is immediate. If it does not receive a response, the second attempt starts after approximately 5 minutes. Further attempts start approximately 10, 20, 40 and 60 minutes after the preceding attempt started, then every 60 minutes without an attempt limit. A connection is never held for more than 5 minutes; Billing waits silently for the remainder of longer intervals. Redirects, transport errors and non-2xx responses are failures.

Prefer persisting the event quickly, returning 2xx, and doing expensive work asynchronously. If processing succeeds after the original connection has already closed, acknowledge the event separately with the same project's Service Key:

```http
POST /api/v1/webhook-events/<eventId>/ack
Authorization: Bearer <SERVICE_KEY>
```

The acknowledgement is idempotent, returns `204 No Content`, and stops future retries. A response sent on an already closed HTTP connection cannot be observed by Billing, so use this endpoint for that case. It does not replace the required UNIQUE constraint on `eventId`.

Billing Admin shows the payment, order parameters, outgoing event body, every delivery attempt, HTTP status, bounded response preview and next attempt time. Secrets and HMAC headers are never stored in this history.

The example below is webhook contract v2. Select it when creating or updating the project with `webhookPayloadVersion: 2`. Version 1 is the backwards-compatible default and omits `tariffExternalId`, `amountRubles`, `receiptEmail`, and `billingType`. The selected version is stored with each event, so changing the project later does not change the body of an event that is already being retried. `currentPeriodStart` is available from the subscription endpoint and is intentionally not included in outgoing events; use `currentPeriodEnd` to grant or extend access.

```http
POST <PROJECT_WEBHOOK_URL>
X-FanLead-Event-Id: 6872274e-…
X-FanLead-Timestamp: 1787392800
X-FanLead-Signature: v1=<HMAC_SHA256_HEX>
Content-Type: application/json
```

```json
{
  "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-…"
  }
}
```

Event types:

```text
payment.succeeded
subscription.renewed
subscription.payment_failed
subscription.canceled
```

In v2, `amountKopecks` remains the canonical integer amount for calculations. `amountRubles` is the same amount formatted as a string with exactly two decimal places, so JSON floating-point rounding cannot lose kopecks. `tariffExternalId` is the service-defined tariff identifier such as `balance-1500`; integrations do not need to maintain a mapping from the Billing tariff UUID.

In v2, the final `receiptEmail` is included only in `payment.succeeded` and `subscription.renewed`. Billing takes it from the actual payment request after any edit on the checkout page. Treat it as personal data. The public checkout receives only a mask; the full final address is available in the signed webhook and Service-Key-protected order API, but never placed in the URL, public API or change history.

A balance-topup `payment.succeeded` additionally contains `billingType: "balance_topup"`; its `periodMonths`, `subscriptionId`, `currentPeriodEnd`, and `nextChargeAt` are `null`.

### HMAC verification

Calculate the signature from the exact raw request body before JSON parsing:

```text
signedPayload = X-FanLead-Timestamp + "." + RAW_HTTP_BODY
expected = hex(HMAC_SHA256(WEBHOOK_SECRET, signedPayload))
received = value after "v1=" in X-FanLead-Signature
```

Required verification sequence:

1. Preserve the raw HTTP body bytes before JSON parsing.
2. Reject the event when the timestamp differs from the current Unix time by more than 300 seconds.
3. Compare `expected` and `received` in constant time.
4. Require the header event ID to equal `body.eventId`.
5. Require `projectId` to equal your project ID.
6. Verify `externalOrderId`, tariff, resource, amount and currency against your database.
7. Insert `eventId` into a table with a UNIQUE constraint.
8. Insert `eventId` and update access in the same database transaction.
9. Return 2xx only after the transaction commits.
10. For an already processed `eventId`, do not extend access again; return 2xx.

Webhook Secret alone is not enough. HMAC proves the sender, timestamp limits replay age, UNIQUE `eventId` prevents duplicate access, and order verification prevents applying an event to the wrong resource.

## Security checklist before launch

- Admin Key, Service Key and Webhook Secret are stored separately in server-side secrets.
- No secret appears in frontend code, URLs, Git or logs.
- At least one active tariff exists and every period amount has been tested.
- Repeating checkout creation with the same `Idempotency-Key` returns the same checkout.
- Access is granted only after a valid webhook or server-to-server order verification.
- An invalid signature or timestamp outside ±300 seconds is rejected.
- Repeating the same `eventId` does not extend access twice.
- Order, project, tariff, resource, amount and currency identifiers are verified.
- Every service has a self-service cancellation button backed by the Billing PATCH endpoint.
- Cancellation clears the saved payment method and preserves access until `currentPeriodEnd`.
- The service has a documented key rotation procedure.
