Интеграция с API выставления счетов
Модуль счетов — единая точка выставления счетов на оплату: стороннее приложение создаёт контрагента, выставляет счёт, отправляет его контрагенту через Т-Банк и узнаёт об оплате — всё по одному API.
- Базовый адрес API:
https://<домен кабинета>/api— тот же домен, на котором открыт личный кабинет. При локальной разработке —http://localhost:3001/api. - Все тела запросов и ответов — JSON (
Content-Type: application/json), кроме печатных форм (/print,/pdf). - Все суммы — целые копейки.
3500000= 35 000,00 ₽. Дробных чисел в денежных полях нет. - Коды и идентификаторы — английские, тексты и сообщения об ошибках — русские.
- CORS включён, запросы можно слать и из браузера.
Полный список маршрутов — в справочнике; здесь сценарий с рабочими curl.
Оглавление: С чего начать · Авторизация · Контрагенты · Проекты · Выставление счёта · Идемпотентность · Нумерация · Отправка в Т-Банк · Проверка оплаты · Вебхуки · Неразнесённые поступления · Печать · Коды ошибок · Полный сценарий
С чего начать
Интегратору нужен только ключ приложения. Его выпускает администратор компании в личном кабинете — «Администрирование → Ключи приложений» — и видит один раз. Если ключ у вас уже есть, переходите к Авторизации.
Ниже тот же путь целиком через API — для тех, кто разворачивает Поток сам или
автоматизирует настройку. Четыре шага, понадобятся curl и jq. В кабинете
каждому шагу соответствует свой экран в разделе «Настройки»: «Реквизиты и
налоги», «Банковские счета», «Ключи приложений».
Шаг 1. Администратор
Регистрация открыта только на пустой базе: так заводится первый
администратор. После этого она закрыта, а сотрудников добавляет
администратор в «Настройках» → «Пользователи» (POST /api/users):
BASE=https://app.example.ru/api # домен вашего кабинета
ADMIN_TOKEN=$(curl -s -X POST "$BASE/auth/register" \
-H 'Content-Type: application/json' \
-d '{"email":"admin@romashka.ru","password":"secret123","name":"Иван Иванов"}' \
| jq -r .token)
Ответ 201:
{
"token": "eyJhbGciOi...",
"user": {
"id": "…",
"email": "admin@romashka.ru",
"name": "Иван Иванов",
"createdAt": "…",
"isPlatformAdmin": true,
"memberships": [
{ "tenantId": "000000000000000000000001", "tenantName": "Моя компания", "tenantInn": null, "tenantStatus": "active", "role": "admin" }
],
"tenantId": "000000000000000000000001",
"role": "admin"
}
}
Роль — у членства в компании, а не у человека: в каждой компании из
memberships она своя. Пока компания одна, заголовок X-Tenant-Id можно не
передавать — сервер возьмёт единственную. Если компаний у человека несколько,
передавайте его в каждом запросе, иначе 400 (см. «Компания запроса» в
справочнике). Отказы по компании приходят с
"code": "tenant_access": например, человека убрали из компании, пока он
работал, — перечитайте GET /auth/me и выберите компанию из оставшихся.
Если пользователи уже есть, /auth/register закрыт — вернётся 403. Тогда
войдите: POST /auth/login с email/password → 200 с такой же парой
token/user. Свою компанию в общем «Потоке» регистрируют через
POST /auth/signup (или кнопку «Подключиться» на сайте): она заработает,
когда вы подтвердите почту и оператор её одобрит — до этого запросы к её
данным отвечают 403 с "code": "tenant_inactive".
Шаг 2. Реквизиты компании
Без названия компании и ИНН выставить счёт нельзя (получите 409). Реквизиты
заполняет администратор:
curl -s -X PATCH "$BASE/settings" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"companyName": "ООО «Ромашка»",
"inn": "7701234567",
"kpp": "770101001",
"ogrn": "1157746123456",
"legalAddress": "127006, Москва, ул. Тверская, 1",
"directorName": "Генеральный директор Иванов И.И."
}'
Ответ 200 — полный объект настроек. Полезные поля: timezone (IANA, по
умолчанию Europe/Moscow — по ней считаются даты документов), invoiceDueDays
(срок оплаты по умолчанию, дней; по умолчанию 5), paymentCheckCooldownSec
(см. Проверка оплаты).
Шаг 3. Банковский счёт
Счета выставляются от имени компании в Т-Банке. Подключите расчётный счёт — токен выдаётся в личном кабинете Т-Бизнеса:
curl -s -X POST "$BASE/bank-accounts" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"accountNumber": "40802810800000012345",
"tbankToken": "<токен из личного кабинета Т-Бизнеса>",
"corrAccount": "30101810100000000100"
}'
accountNumber— ровно 20 цифр;corrAccount— 20 цифр (необязательно, но нужен в печатной форме);- API проверяет токен в банке и что счёт принадлежит компании: неверный токен →
400, счёт не из списка банковских →400с перечнем доступных, банк недоступен →502; - успех →
201с объектом счёта (idпригодится, если у компании их несколько); - повторное подключение того же счёта →
409. Сменить токен у подключённого счёта —PUT /bank-accounts/:id/token: история операций останется на месте; - токен Т-Банка привязан к IP: при выпуске добавьте в разрешённые исходящий IP
«Потока» — он в
egressIpsответаGET /onboardingи в кабинете. Иначе банк отклонит проверку даже верного токена (к тексту ошибки добавится подсказка); - тариф: первый подключённый счёт бесплатный, каждый следующий — 1000 ₽ в месяц
(сводка —
GET /billing, у каждого счёта — признакfree).
Без подключённого счёта (точнее, без его номера и БИК) выставление вернёт 409.
Шаг 4. Ключ приложения
Ключ выпускает администратор:
curl -s -X POST "$BASE/api-clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "Моё приложение",
"scopes": ["invoices:read","invoices:write","counterparties:read","counterparties:write"]
}'
Ответ 201:
{
"client": { "id": "…", "name": "Моё приложение", "keyTail": "…a1b2", "scopes": ["…"], "…": "…" },
"key": "ooo_sk_<64 шестнадцатеричных символа>"
}
Ключ показывается только один раз — в этом ответе. В базе хранится лишь SHA-256-хеш: потерянный ключ восстановить нельзя, можно только выпустить новый и отозвать старый. Сохраните
keyсразу.
Необязательное поле seriesId привязывает приложение к отдельной серии
нумерации. Управление ключами: GET /api-clients (список), PATCH /api-clients/:id
(имя, scopes, серия), POST /api-clients/:id/revoke (отзыв; используется
пользовательский JWT, не ключ приложения).
Дальше все запросы идут с ключом приложения — JWT больше не нужен.
Авторизация
Каждый запрос несёт заголовок:
Authorization: Bearer ooo_sk_…ключ…
Ключи приложений начинаются с префикса ooo_sk_. Тот же формат заголовка
принимает и пользовательский JWT (веб-интерфейс работает через него), но для
интеграции нужен именно ключ: только у ключей работает
идемпотентность и только они видны в истории
счетов как «кто выставил».
Права ключа задаются списком scopes при выпуске:
| Scope | Что даёт |
|---|---|
invoices:read |
Чтение счетов, истории, статуса оплаты, статуса в банке, печать |
invoices:write |
Создание, изменение, выставление, аннулирование, отправка в банк |
counterparties:read |
Чтение контрагентов |
counterparties:write |
Создание и изменение контрагентов |
Ошибки авторизации:
- заголовка нет / ключ не найден →
401 {"error":"Требуется авторизация"}; - ключ отозван (
POST /api-clients/:id/revoke) →401 {"error":"Ключ отозван"}; - право не выдано →
403 {"error":"Недостаточно прав: нужен доступ «invoices:write»"}.
Контрагенты
Создание с дедупликацией по ИНН
POST /api/counterparties
curl -s -X POST "$BASE/counterparties" \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "ООО «Покупатель»",
"kind": "legal",
"inn": "7704123456",
"kpp": "770401001",
"legalAddress": "123456, Москва, ул. Лесная, 5",
"email": "buh@pokupatel.ru"
}'
Поля: name — обязательно; kind — legal (юр. лицо, по умолчанию) /
entrepreneur (ИП) / individual (физлицо); остальные необязательны: inn
(до 12 символов), kpp (до 9), ogrn (до 15), legalAddress, email,
phone, comment.
- Новая карточка →
201с объектом контрагента (полеid— его идентификатор). - Контрагент с таким ИНН уже есть →
200и существующая карточка, без создания второй.
Дедупликация — важное свойство: перед выставлением счёта можно смело
отправлять контрагента «как в первый раз» при каждом заказе и не хранить у
себя идентификаторы — по ИНН всегда вернётся та же карточка. Дедупликация
работает по непустому inn; карточки без ИНН (физлица) создаются каждую заново.
Ответ:
{
"id": "68f0…",
"name": "ООО «Покупатель»",
"kind": "legal",
"inn": "7704123456",
"kpp": "770401001",
"ogrn": "",
"legalAddress": "123456, Москва, ул. Лесная, 5",
"email": "buh@pokupatel.ru",
"phone": "",
"comment": "",
"createdAt": "2026-09-10T10:00:00.000Z"
}
Ошибки: 400 — нет name (Укажите название контрагента); 401/403 — см.
авторизацию.
Остальное
GET /api/counterparties?q=поиск— список (поиск по названию и ИНН, до 500, по алфавиту); без параметраq— все. Ответ — просто массив объектов.PATCH /api/counterparties/:id— изменение любых полей;400«Нечего обновлять» для пустого тела. Внимание: счета хранят снимок реквизитов контрагента на момент выставления, изменение карточки задним числом уже выставленные счета не меняет.DELETE /api/counterparties/:id—200 {"ok":true};409, если по контрагенту есть счета (карточка нужна сверке платежей).
Проекты
Проект — то, подо что выставляют счета: услуга, направление, договор. Его код
из двух цифр стоит внутри номера счёта: 26020007 — седьмой счёт 2026 года
по проекту 02. Счета вне проектов идут с кодом 00.
Справочник живёт в кабинете (/api/projects, доступ по JWT — ключу приложения
он не открыт). Стороннее приложение проект не заводит, а использует его код
при создании счёта:
{ "projectCode": "02", "counterpartyId": "…", "items": [ … ] }
Поле необязательное: без него счёт получает 00. Код должен быть ровно из двух
цифр, иначе 400. Заводить проект под код заранее не требуется — номер
сформируется в любом случае, — но тогда в кабинете вместо названия будет видна
только цифра.
Список счетов фильтруется по проекту:
GET /api/invoices?projectCode=02
projectCode=00 — счета вне проектов, это тоже осмысленный фильтр.
Выставление счёта
POST /api/invoices
Жизненный цикл: draft (черновик) → issued (выставлен) → partially_paid →
paid, отдельно cancelled. Счёт создаётся черновиком; issue: true
выставляет его сразу, одним запросом. Выставленный счёт — юридический документ:
позиции, суммы, стороны и дата замораживаются.
Выставление (
issue: trueилиPOST /invoices/:id/issue) необратимо: номер получен, счёт виден в отчётности. Скрыть его можно только аннулированием (POST /invoices/:id/cancel), номер при этом остаётся занят.
Пример: сразу выставленный счёт
curl -s -X POST "$BASE/invoices" \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{
"counterpartyId": "68f0…",
"externalId": "order-2026-000123",
"projectCode": "01",
"issue": true,
"pricesIncludeVat": true,
"basis": "Договор № 5 от 01.09.2026",
"dueOn": "2026-09-25",
"items": [
{
"name": "Разработка мобильного приложения",
"unit": "услуга",
"quantityMilli": 1000,
"priceKopecks": 3500000,
"vatRate": "vat_22"
},
{
"name": "Техподдержка, месяц",
"unit": "мес.",
"quantityMilli": 1500,
"priceKopecks": 500000,
"vatRate": "none"
}
]
}'
Поля запроса
| Поле | Тип | Обяз. | По умолчанию | Значение |
|---|---|---|---|---|
counterpartyId |
string | да | — | id контрагента |
items |
array | да | — | минимум 1 позиция |
issue |
boolean | нет | false |
true — сразу выставить |
externalId |
string | нет | — | идемпотентность, до 120 симв. |
projectCode |
string | нет | "00" |
код проекта, ровно 2 цифры — попадает в номер |
seriesId |
string | нет | серия ключа или «Основная» | серия нумерации |
bankAccountId |
string | нет | первый подключённый счёт | счёт для реквизитов оплаты |
issuedOn |
string | нет | сегодня в зоне компании | дата счёта, ISO |
dueOn |
string|null | нет | issuedOn + invoiceDueDays дней |
срок оплаты; null — без срока |
basis |
string | нет | "" |
основание (договор), до 500 симв. |
comment |
string | нет | "" |
комментарий, до 1000 симв. |
pricesIncludeVat |
boolean | нет | false |
трактуют ли цены позиций НДС (см. ниже) |
sendToBank |
boolean | нет | false |
после выставления отправить в Т-Банк; только вместе с issue: true |
Позиции (items[])
| Поле | Тип | Обяз. | По умолчанию | Значение |
|---|---|---|---|---|
name |
string | да | — | наименование |
article |
string | нет | "" |
артикул (код номенклатуры), до 40 симв. Печатается колонкой «Артикул» — её нет, если артикул не заполнен ни в одной позиции. |
unit |
string | нет | "шт." |
единица, до 20 симв. |
quantityMilli |
int | да | — | количество в тысячных: 1000 = 1 шт., 1500 = 1,5, 250 = 0,25. Минимум 1. |
priceKopecks |
int | да | — | цена за единицу в копейках: 3500000 = 35 000,00 ₽. От 0. |
vatRate |
string | нет | "none" |
none / vat_0 / vat_5 / vat_7 / vat_10 / vat_20 / vat_22 |
Итоги (netKopecks, vatKopecks, totalKopecks) считаются на сервере и
приходят в ответе — передавать их не нужно.
pricesIncludeVat и НДС
Поле на уровне счёта, определяет смысл priceKopecks:
false(по умолчанию) — цена без НДС, НДС начисляется сверху: 2 шт. × 100,00 ₽ сvat_22→ нетто 200,00 ₽, НДС 44,00 ₽, итог 244,00 ₽;true— цена включает НДС, НДС выделяется из неё по расчётной ставке (22/122): 1 шт. × 122,00 ₽ сvat_22→ нетто 100,00 ₽, НДС 22,00 ₽, итог 122,00 ₽.
Ставки none и vat_0 на расчёт не влияют: сумма равна цена × количество.
Счета с
pricesIncludeVat: falseи ненулевой ставкой НДС пока не отправляются в Т-Банк (см. ограничения отправки).
Ответ
201 (повтор с тем же externalId — 200, см. ниже) — объект счёта:
{
"id": "680a…",
"number": "26010001",
"status": "issued",
"projectCode": "01",
"issuedOn": "2026-09-10T00:00:00.000Z",
"dueOn": "2026-09-25T00:00:00.000Z",
"counterpartyId": "68f0…",
"counterparty": { "name": "ООО «Покупатель»", "inn": "7704123456", "…": "…" },
"seller": { "name": "ООО «Ромашка»", "inn": "7701234567", "…": "…" },
"bank": { "accountNumber": "40802810800000012345", "bic": "…", "…": "…" },
"items": [
{ "id": "…", "name": "Разработка мобильного приложения", "unit": "услуга",
"quantityMilli": 1000, "priceKopecks": 3500000, "vatRate": "vat_22",
"netKopecks": 2868852, "vatKopecks": 631148, "totalKopecks": 3500000 },
{ "id": "…", "name": "Техподдержка, месяц", "unit": "мес.",
"quantityMilli": 1500, "priceKopecks": 500000, "vatRate": "none",
"netKopecks": 750000, "vatKopecks": 0, "totalKopecks": 750000 }
],
"pricesIncludeVat": true,
"netKopecks": 3618852,
"vatKopecks": 631148,
"totalKopecks": 4250000,
"paidKopecks": 0,
"overpaidKopecks": 0,
"dueKopecks": 4250000,
"paidAt": null,
"payments": [],
"basis": "Договор № 5 от 01.09.2026",
"comment": "",
"externalId": "order-2026-000123",
"createdByClientName": "Моё приложение",
"tbank": null,
"createdAt": "…", "updatedAt": "…"
}
У черновика (issue: false) number пустой и status: "draft".
Остальные операции
GET /api/invoices?status=&counterpartyId=&externalId=&from=&to=&q=&page=&limit=— список; ответ{"items":[…], "total": N};externalIdв фильтре — удобный способ найти «свой» счёт.limitдо 200, по умолчанию 50.GET /api/invoices/:id— один счёт;404«Счёт не найден».PATCH /api/invoices/:id— черновик меняется целиком (те же поля, кромеexternalId/issue/sendToBank); выставленный — толькоdueOnиcomment, попытка изменить остальное →409; оплаченный/аннулированный не меняется вовсе →409.POST /api/invoices/:id/issue— выставить черновик отдельно.POST /api/invoices/:id/cancel— аннулировать (нельзя только оплаченный). Если по счёту уже есть оплата, ответ дополняется полемwarningс её суммой.DELETE /api/invoices/:id— только черновик; иначе409«Удалить можно только черновик. Выставленный счёт аннулируйте.»GET /api/invoices/:id/history— журнал изменений (кто, когда, что).
Идемпотентность: externalId
externalId — идентификатор счёта на стороне вашего приложения (номер
заказа, платежа и т.п.). Это защита от дублей при ретраях:
- повторный
POST /api/invoicesс тем жеexternalIdи тем же ключом возвращает уже созданный счёт с кодом200, а не создаёт второй; - номер серии при этом не тратится — нового счёта просто нет;
- ретрай по таймауту сети безопасен: «не дозвонились — шлём тот же запрос ещё раз» не может дать два счёта, в том числе при параллельных ретраях;
- уникальность
externalId— в пределах одного ключа приложения: разные приложения могут независимо использовать одинаковые значения.
Передавайте externalId в каждом создании счёта. Найти счёт по нему можно и в
списке: GET /api/invoices?externalId=order-2026-000123.
Важно: идемпотентность работает только для ключей приложений. От
пользовательского JWT (Bearer eyJ…) поле externalId молча игнорируется и в
документ не пишется.
Дедупликация контрагентов по ИНН (выше) устроена так же и по той же причине.
Нумерация
Номер формата ГГППНННН: две цифры года, две цифры кода проекта, четыре
цифры сквозного номера внутри серии за период. Пример: счёт 2026 года, проект
01, первый в году → 26010001; тот же год без проекта → 26000001.
- Код проекта — поле
projectCodeв запросе: ровно две цифры ("01"…"99"), иначе400. Не передано или00— счёт вне проекта. - Номер присваивается при выставлении, а не при создании черновика — у
черновика
numberпустой, брошенные черновики не оставляют дыр. - После 9999 счетов за период номер становится длиннее (10000-й →
260110000) — это допустимо. - Сброс сквозного номера — раз в год (серия «Основная», формат
year_project). - Аннулированный счёт номер сохраняет за собой, повторно он не используется.
- Номер из одних цифр — осознанное требование Т-Банка (1–15 цифр), поэтому
отправка в банк работает именно с такими сериями. Серии с буквенными
префиксами (
plain) для отправки в банк не подходят — их счета доставляйте печатной формой.
У ключа приложения может быть своя серия (seriesId при выпуске ключа или в
запросе) — тогда «свои» приложения не смешивают нумерацию с чужими.
Отправка в Т-Банк
Отправленный в Т-Банк счёт появляется у контрагента с кнопкой «Оплатить».
Способ 1: вместе с созданием
sendToBank: true в POST /api/invoices — но только вместе с issue: true
(иначе 400). Счёт сначала выставляется, затем уходит в банк; отказ банка не
отменяет выставление — ошибка видна в tbank.error ответа, повторная отправка
— эндпоинтом ниже.
Способ 2: отдельно
POST /api/invoices/:id/send-to-bank
Тело необязательное:
{ "contacts": ["buh@pokupatel.ru"], "contactPhone": "+79001234567", "force": false }
contacts— до 10 адресов email, на которые банк разошлёт письмо со счётом;contactPhone— телефон для SMS от банка;force: true— отправить повторно, даже если счёт уже в банке (например, контрагент потерял письмо).
Рассылка (
contacts,contactPhone) — внешнее действие без отмены: письмо и SMS контрагенту уходят сразу. Из карточки контрагента адреса никогда не подставляются — только явно переданные в запросе. БезcontactsиcontactPhoneникто никуда не пишет: счёт просто появляется в банке.
Успех — 200:
{
"invoice": { "…": "…", "tbank": {
"invoiceId": "TnkInv_…",
"pdfUrl": "https://…/invoice.pdf",
"incomingInvoiceUrl": "https://…/pay",
"sentAt": "2026-09-10T10:05:00.000Z",
"mode": "sandbox",
"error": null
} },
"alreadySent": false
}
invoice.tbank.invoiceId— идентификатор счёта в банке;invoice.tbank.pdfUrl— готовый PDF счёта у банка (в песочнице — заглушка);invoice.tbank.incomingInvoiceUrl— страница оплаты, «та самая ссылка»;alreadySent: true— счёт уже был в банке, повторной отправки не было, ссылки из базы;invoice.tbank.error— текст последней неудачи (null— всё хорошо).
Куда приходит счёт. Если у контрагента указан корректный ИНН (10 цифр для
юрлица, 12 для ИП) и он — клиент Т-Банка, счёт падает ему в личный кабинет
Т-Бизнеса. Остальным контрагентам счёт доступен по ссылке
incomingInvoiceUrl/pdfUrl — её можно показать в вашем интерфейсе или
отправить самому (email/SMS от банка — только через contacts/contactPhone).
Статус в банке. GET /api/invoices/:id/bank-status — статус как его отдаёт
банк (например SUBMITTED), по счётам, которые в банк не уходили — 409.
Ограничения отправки (код 422 rejected)
Отказ «до сети» — счёт в текущем виде не отправляется, текст в error:
- номер не из цифр или длиннее 15 знаков (серия с буквенным префиксом);
- цены без НДС (
pricesIncludeVat: false) с ненулевой ставкой — пока не отправляются: банк не различает цены с НДС и без, суммы разошлись бы; выставляйте счёт с ценами, включающими НДС, или доставляйте печатную форму; - в реквизитах нет расчётного счёта из 20 цифр / названия контрагента;
- больше 100 позиций;
- счёт не привязан к подключённому счёту Т-Банка.
Отправка черновика или не-issued счёта → 409 conflict. Параллельная
отправка того же счёта → 409 busy (повторите через минуту). Ошибка/недоступность
банка → 502 с текстом; выставленный статус счёта при этом не страдает.
Проверка оплаты
GET /api/invoices/:id/payment-status
Опрашивать можно так часто, как угодно: в пределах cooldown
(paymentCheckCooldownSec настроек, по умолчанию 300 секунд) статус отдаётся из
базы — запрос в банк не идёт. Когда пауза истекла, один из запросов тянет свежую
выписку и разносит оплаты (в т.ч. частичные) по счетам.
Опрашивать и не обязательно: «Поток» сам забирает выписку и разносит оплаты по
расписанию — раз в paymentPollMinutes минут (настройки компании: 5, 15, 30 или
60; 0 — только вручную; по умолчанию 15). Окно у банковского счёта общее: если
только что сходил планировщик, запрос статуса в банк не пойдёт, и наоборот.
{
"invoiceId": "680a…",
"number": "26010001",
"status": "partially_paid",
"totalKopecks": 4000000,
"paidKopecks": 1500000,
"dueKopecks": 2500000,
"paidAt": null,
"payments": [
{ "transactionId": "…", "amountKopecks": 1500000, "date": "…",
"matchedAt": "…", "reason": "number_in_purpose", "confidence": 90 }
],
"checkedAt": "2026-09-10T10:10:00.000Z",
"nextCheckAt": "2026-09-10T10:15:00.000Z",
"cooldownRemainingMs": 137000,
"refreshed": false,
"syncError": null
}
status—issued→partially_paid→paidпо мере поступления денег;paidKopecks/dueKopecks— оплачено / осталось, в копейках;overpaidKopecksв счёте показывает переплату;payments[]— привязанные поступления с причиной (reason) и уверенностью сверки (confidence);checkedAt/nextCheckAt/cooldownRemainingMs— когда последний раз сверялись с банком и когда можно снова;refreshed: true— именно этот запрос сходил в банк за выпиской;false— отдано состояние из базы (в пределах cooldown, или счёт — черновик/оплачен/ аннулированный, которым банк не нужен);syncError— текст ошибки синхронизации, если она была.
Ошибка банка не роняет запрос: всегда 200, при сбое синхронизации статус
вернётся из базы, а текст ошибки — в syncError; cooldown при этом взводится,
чтобы каждый следующий опрос не бился в недоступный банк. Логика на вашей
стороне: syncError != null → показать/залогировать и попробовать позже.
Вебхуки
Вместо опроса статуса «Поток» может сам сообщать вашему приложению о событиях:
| Событие | Когда |
|---|---|
invoice.issued |
счёт выставлен (получил номер) |
invoice.paid |
счёт оплачен полностью |
invoice.partially_paid |
к счёту привязан платёж, но оплачен он не целиком |
invoice.payment_removed |
бухгалтер отвязал платёж от счёта — оплата, на которую вы могли отгрузить, признана чужой |
invoice.cancelled |
счёт аннулирован |
payment.received |
на расчётный счёт пришли деньги (новая входящая операция в выписке) |
document.issued |
документ продажи выставлен: акт, накладная (ТОРГ-12) или УПД — по нему покупатель закрывает свою сторону |
document.cancelled |
документ продажи аннулирован; номер остаётся за ним и другому документу не достанется |
automation.task.ready |
задача конвейера автоматизации готова к действию человека: подготовлен черновик или требуется решение |
automation.task.failed |
задача конвейера автоматизации упала — с причиной в error |
Адрес задаётся в кабинете («Настройки» → «Вебхуки») или через API ключом
со скоупом webhooks:write:
curl -s -X POST "$BASE/webhooks" \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://shop.example.ru/potok/webhook", "events": ["invoice.paid", "invoice.partially_paid"]}'
В ответе 201 — secret (whsec_…). Он показывается один раз: сохраните
его в приёмнике. Потеряли — POST /webhooks/:id/rotate-secret, старый перестанет
подходить сразу.
Что приходит
POST на ваш адрес с JSON-телом и заголовками:
X-Potok-Event— тип события;X-Potok-Delivery— id доставки (при повторе — тот же);X-Potok-Signature—t=<unix-время>,v1=<HMAC-SHA256>.
Тело — всегда один конверт: id события, type, createdAt и data.
{
"id": "evt_4f0c…",
"type": "invoice.paid",
"createdAt": "2026-09-11T10:15:00.000Z",
"data": { "invoice": { "id": "…", "number": "26050001", "status": "paid", "paidKopecks": 150000, "…": "…" } }
}
Что лежит в data:
| Событие | data |
|---|---|
invoice.issued |
invoice — счёт целиком, как в GET /invoices/:id, уже с номером и status: "issued" |
invoice.paid |
invoice — счёт после привязки оплаты: status: "paid", paidKopecks, paidAt, payments[] |
invoice.partially_paid |
invoice — status: "partially_paid"; сколько пришло — paidKopecks, сколько осталось — dueKopecks |
invoice.payment_removed |
invoice — счёт уже после отвязки: статус вернулся к issued или partially_paid, отвязанного платежа в payments[] нет |
invoice.cancelled |
invoice — status: "cancelled"; оплата, если была, остаётся за счётом |
payment.received |
payment — id, bankAccountId, date, amountKopecks, counterpartyName, counterpartyInn, purpose |
document.issued |
document — документ целиком, как в GET /sales-documents/:id: kind (act, waybill, upd), number, issuedOn, invoiceId, items[], суммы |
document.cancelled |
document — status: "cancelled", cancelledAt и cancelReason; номер и позиции остаются прежними |
automation.task.ready |
task — id задачи в GET /automation/tasks/:id, kind (payroll_register/filing/payment_order/notification), naturalKey, status (needs_review/draft/awaiting_signature) |
automation.task.failed |
task — id задачи, kind, naturalKey и error — причина сбоя (без секретов и содержимого файлов) |
ping |
message — только для проверки приёмника (POST /webhooks/:id/test); подписываться на него не нужно |
invoice — тот же объект, что отдаёт GET /invoices/:id (см. раздел
«Выставление счёта» → «Ответ»), поэтому состояние счёта можно брать прямо из
события, не запрашивая его заново.
Одно событие может прийти дважды (например, если ваш ответ не дошёл до нас) —
отбрасывайте повторы по id события.
Проверка подписи
Подпись — HMAC-SHA256 секретом от строки <t>.<тело запроса как есть>.
Проверяйте её до разбора JSON и отбрасывайте запросы старше нескольких минут —
так перехваченный запрос нельзя прислать повторно:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPotokSignature(secret: string, header: string, rawBody: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // старше 5 минут
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const got = Buffer.from(String(parts.v1 ?? ''), 'hex');
return got.length === 32 && timingSafeEqual(got, Buffer.from(expected, 'hex'));
}
Ответ и повторы
Ответьте 2xx быстро — за 10 секунд, работу делайте после ответа. Всё
остальное (другой код, таймаут, обрыв) — неуспех: «Поток» повторит через минуту,
5 минут, полчаса, 2, 6, 12 и 24 часа — около двух суток. Переадресации (3xx) не
выполняются — укажите окончательный адрес. Журнал доставок с кодами ответов —
GET /webhooks/:id/deliveries и в кабинете; проверить приёмник — POST /webhooks/:id/test (событие ping).
Адрес — только https, и не во внутреннюю сеть (частные, loopback и link-local
адреса запрещены).
Неразнесённые поступления
GET /api/invoices/payments/unmatched?page=1&limit=20
Входящие платежи, которые автосверка не привязала ни к одному счёту. Это очередь на решение человека, и попасть в неё платёж может по двум причинам: счёт есть, но сопоставить не удалось, — или счёта на эти деньги не было вовсе.
Из очереди платёж уходит двумя способами:
- Привязкой к счёту —
POST /api/invoices/:id/paymentsсtransactionId. - Указанием вида платежа, которым нельзя оплатить счёт. Взнос учредителя,
заём, возврат займа, возврат от поставщика и перевод между своими счетами
счетами не оплачиваются — счёт на них никто не выставлял. Такой платёж
разбирается проставлением вида (
PATCH /api/transactions/:idсkindId) и исчезает из очереди.
Выручка, эквайринг и поступление от агента из очереди не уходят до
привязки: за выручкой счёт стоять обязан. За это отвечает признак
canSettleInvoice у вида платежа (GET /api/payment-kinds).
Печать
GET /api/invoices/:id/print → text/html — печатная форма под А4
GET /api/invoices/:id/pdf → application/pdf
- Заголовок
Authorization: Bearer <ключ>обязателен — открыть ссылку «напрямую» в браузере нельзя (без заголовка запрос вернёт401). Скачивайте своим HTTP-клиентом и прикладывайте к письму/файлом:curl -s "$BASE/invoices/$ID/pdf" -H "Authorization: Bearer $KEY" -o invoice.pdf GET /pdfотдаёт файл сContent-Disposition: attachment;?inline=1— для просмотра в браузере вместо скачивания.- Требуется scope
invoices:read; несуществующий счёт →404.
Коды ошибок
Ошибка — всегда JSON с полем error: текст по-русски, его можно показывать
человеку как есть. Иногда рядом есть поля для программы:
fields— ошибки по полям формы при400проверки реквизитов (ИНН, КПП, ОГРН):{"error": "…", "fields": {"inn": "Неверная контрольная сумма ИНН"}};code— при отказе по компании запроса:tenant_access(нет доступа к компании или не выбрана из нескольких — выберите заново поGET /auth/me) иtenant_inactive(компания на проверке, отклонена или приостановлена — данные закрыты, причина в тексте). Касается и ключей приложений: ключ приостановленной компании получает403сtenant_inactive.
| Код | Когда | Что делать |
|---|---|---|
400 |
Тело не JSON; не прошли zod-схемы (нет обязательного поля, projectCode не 2 цифры, отрицательная цена…); неизвестный counterpartyId/seriesId/bankAccountId; кривые параметры фильтров; sendToBank без issue |
Проверить запрос по тексту ошибки, не ретраить вслепую |
401 |
Нет заголовка; ключ неизвестен; ключ отозван | Проверить ключ (ooo_sk_…), выпустить новый, если старый потерян/отозван |
402 |
Не оплачен тариф «Потока»: синхронизация платного расчётного счёта после 7 дней просрочки; во время просрочки — новые ключи, вебхуки, счета и приглашения | Оплатить счёт за тариф (он в кабинете: «Настройки» → «Банковские счета»); выставлять счета и проверять оплату можно и без этого |
403 |
Право (scope) не выдано ключу; компания не работает (code: "tenant_inactive"); нет доступа к компании (code: "tenant_access") |
Для scope — попросить админа добавить его ключу; для компании — прочитать текст ошибки |
404 |
Объект по :id не найден (в т.ч. кривой формат id); неизвестный путь ({"error":"Не найдено"}) |
Проверить идентификатор |
409 |
Конфликт состояния: счёт уже выставлен; дата в закрытом периоде нумерации; не заполнены реквизиты для выставления (название/ИНН компании, счёт/БИК); изменение выставленного счёта; удаление не-черновика; контрагент со счетами; send-to-bank для черновика/не-issued/параллельной отправки (busy) |
Прочитать текст — обычно это состояние домена, а не сбой; для busy повторить через минуту |
422 |
Счёт не отправляется в Т-Банк в текущем виде (rejected): номер не из цифр, цены без НДС с ненулевой ставкой, >100 позиций и т.п. |
Исправить счёт (в черновике) или доставлять печатной формой |
429 |
Слишком часто: регистрации и заявки с одного адреса за сутки, повторное письмо подтверждения почты чаще раза в минуту | Подождать — время указано в тексте |
502 |
Банк недоступен или вернул ошибку: отправка (bank_error), подключение счёта, синхронизация выписки |
Ретрай с паузой; состояние счетов не портится |
503 |
Сервис не настроен: нет ключа шифрования токенов банка; регистрация компаний закрыта, пока не настроена почта | Сообщить владельцам инстанса |
500 |
Внутренняя ошибка сервера ({"error":"Внутренняя ошибка сервера"}) |
Ретрай позже; если повторяется — сообщить владельцам инстанса |
Пример разбора в коде: 409 на POST /invoices с текстом «Счёт нельзя
выставить: не заполнены реквизиты — …» означает, что администратор не завершил
шаги 2–3 раздела «С чего начать».
Полный сценарий: от ключа до оплаты
Один блок, выполняется подряд (нужны curl и jq). Рассчитан на чистую
базу: только на ней открыта регистрация, и первый пользователь станет
администратором.
BASE=https://app.example.ru/api # домен вашего кабинета
# --- 1. Администратор (первый пользователь) и его JWT ---
ADMIN_TOKEN=$(curl -s -X POST "$BASE/auth/register" \
-H 'Content-Type: application/json' \
-d '{"email":"admin@romashka.ru","password":"secret123","name":"Иван Иванов"}' \
| jq -r .token)
# --- 2. Реквизиты компании (без них выставление даст 409) ---
curl -s -X PATCH "$BASE/settings" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"companyName":"ООО «Ромашка»","inn":"7701234567","kpp":"770101001",
"ogrn":"1157746123456","legalAddress":"127006, Москва, ул. Тверская, 1"}' > /dev/null
# --- 3. Подключение расчётного счёта Т-Банка ---
curl -s -X POST "$BASE/bank-accounts" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"accountNumber":"40802810800000012345",
"tbankToken":"ВАШ_ТОКЕН_Т_БИЗНЕСА",
"corrAccount":"30101810100000000100"}' | jq .
# --- 4. Ключ приложения. ПОКАЗЫВАЕТСЯ ОДИН РАЗ — сохраните ---
KEY=$(curl -s -X POST "$BASE/api-clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Моё приложение",
"scopes":["invoices:read","invoices:write","counterparties:read","counterparties:write"]}' \
| jq -r .key)
echo "KEY=$KEY"
# Дальше работаем только с ключом приложения.
# --- 5. Контрагент (повтор с тем же ИНН вернёт его же, код 200) ---
CP_ID=$(curl -s -X POST "$BASE/counterparties" \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"ООО «Покупатель»","kind":"legal","inn":"7704123456","kpp":"770401001"}' \
| jq -r .id)
# --- 6. Счёт: сразу выставить и отправить в Т-Банк.
# externalId делает ретраи безопасными: повтор запроса вернёт этот же счёт (200).
INVOICE=$(curl -s -X POST "$BASE/invoices" \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d "{
\"counterpartyId\":\"$CP_ID\",
\"externalId\":\"order-2026-000123\",
\"projectCode\":\"01\",
\"issue\":true,
\"sendToBank\":true,
\"pricesIncludeVat\":true,
\"basis\":\"Договор № 5 от 01.09.2026\",
\"items\":[
{\"name\":\"Разработка мобильного приложения\",\"unit\":\"услуга\",
\"quantityMilli\":1000,\"priceKopecks\":3500000,\"vatRate\":\"vat_22\"},
{\"name\":\"Техподдержка, месяц\",\"unit\":\"мес.\",
\"quantityMilli\":1500,\"priceKopecks\":500000,\"vatRate\":\"none\"}
]
}")
echo "$INVOICE" | jq '{id, number, status, totalKopecks, tbank}'
INVOICE_ID=$(echo "$INVOICE" | jq -r .id)
# --- 7. Контрагенту не уходило письмо — досылаем явно (внешнее действие!) ---
curl -s -X POST "$BASE/invoices/$INVOICE_ID/send-to-bank" \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"contacts":["buh@pokupatel.ru"]}' | jq '{alreadySent, tbank: .invoice.tbank}'
# --- 8. Опрос оплаты: можно хоть каждую секунду, банк спрашивается раз в cooldown ---
curl -s "$BASE/invoices/$INVOICE_ID/payment-status" \
-H "Authorization: Bearer $KEY" | jq .
# --- 9. PDF себе (прямой ссылкой не открыть — нужен заголовок) ---
curl -s "$BASE/invoices/$INVOICE_ID/pdf" \
-H "Authorization: Bearer $KEY" -o invoice.pdf && ls -l invoice.pdf
Что должно получиться: шаг 6 — счёт с номером вида 26010001, status: "issued" и заполненным tbank (в песочнице — тестовые ссылки); шаг 8 —
"status":"issued", "paidKopecks":0, "refreshed":true/false.