Деньги из приложения
Вы дадите покупателю отменить, приостановить и вернуть подписку прямо в приложении, а своему серверу — брать доплату с сохранённой карты и возвращать деньги.
Покупатель платит на веб-странице, а отменить продление, взять паузу, доплатить или вернуть деньги приходит в приложение. Карта при этом у нас, и всё это делают запросы с вашего сервера. Суммы в них нет: возврат всегда полный, а списание берёт цену из каталога проекта.
Перед началом
Денежные права ключ получает отдельно: общее право *, с которым ключ выпускается по умолчанию, их не покрывает. Каждое открывается своим переключателем в кабинете владельца: Настройки → Платежи → Правила и деньги, группа «Деньги покупателей». Доступа в кабинет у вас нет — попросите владельца проекта. Кто кого зовёт и какие бывают роли, написано на дорожке владельца: «Проект и команда» и «Передача разработчику».
| Что делаете | Запрос | Право ключа | Переключатель владельца |
|---|---|---|---|
| Отменить, приостановить, возобновить подписку, открыть платёжный портал | POST /s2s/v1/subscription/cancel, /pause, /resume, /portal | subscription:manage | Управление подписками через API |
| Списать с сохранённой карты, узнать исход списания | POST /s2s/v1/subscription/charge, GET /s2s/v1/subscription/charge/status | billing:charge | Списания через API |
| Вернуть деньги | POST /s2s/v1/subscription/refund | billing:refund | Возвраты через API |
Порядок действий не важен: включённый переключатель дописывает право и уже выпущенным ключам, перевыпускать ключ не нужно. Выключенный переключатель работает стоп-краном на весь проект: право снимается со всех ключей, и любой ключ, включая утёкший, получает 403 с кодом charge_disabled, refund_disabled или subscription_manage_disabled.
Тестовый ключ денег не двигает, и переключатели на это не влияют. Чтение исхода и управление подпиской тестовым ключом работают.
Группа «Деньги покупателей» — часть платной связки с приложением: на бесплатном тарифе кабинет вместо переключателей показывает предложение перейти на Pro. Пока в проекте не подключена платёжная система, группа стоит с пометкой «закрыто» — сначала владелец подключает платёжную систему.
Отменить подписку из приложения
Нативная кнопка «Отменить подписку» зовёт ваш сервер, а он — наш запрос. Цена в теле выбирает подписку: у покупателя их может быть несколько, и наугад мы не отменяем.
curl -X POST "https://api.subster.ai/s2s/v1/subscription/cancel" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "guid": "<GUID>", "priceId": "price_…" }'
# { "status": "scheduled_cancel", "cancelAtPeriodEnd": true,
# "priceId": "price_…", "expiresAt": "2026-10-21T00:00:00.000Z" }
Отмена плановая: следующего списания не будет, а оплаченное время покупатель дорабатывает — доступ живёт до expiresAt. В приложении так и покажите: «Подписка активна до <дата>, продление отключено».
Состояние видно и в ответе о праве доступа: запись остаётся со статусом active, но приходит с will_renew: false. Когда период закончится, придёт вебхук entitlement.revoked — по нему снимайте платное у себя.
Повтор запроса безопасен: уже отменённая подписка отвечает already_canceled. Неизвестный покупатель, чужой проект и отсутствие действующей подписки с этой ценой дают один и тот же 404 — по ответу нельзя перебрать чужие идентификаторы.
Денег отмена не возвращает — для этого есть возврат. Снять отмену до конца периода можно возобновлением.
Пауза и возобновление
Пауза останавливает оплату, но не отбирает оплаченное: покупатель больше не платит, доступ доживает до конца оплаченного периода и дальше не продлевается. Тело то же, что у отмены.
curl -X POST "https://api.subster.ai/s2s/v1/subscription/pause" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "guid": "<GUID>", "priceId": "price_…" }'
# { "status": "paused", "cancelAtPeriodEnd": false,
# "priceId": "price_…", "expiresAt": "2026-10-21T00:00:00.000Z" }
Срока у паузы нет: она стоит, пока вы её не снимете. В ответе о праве доступа подписка на паузе приходит с will_renew: false, а вам уходит вебхук subscription.paused.
Снимает паузу POST /s2s/v1/subscription/resume с тем же телом. Этот же запрос снимает и плановую отмену, пока оплаченный период не кончился. Нового платежа он не создаёт.
status в ответе | Что произошло |
|---|---|
paused | подписка поставлена на паузу |
already_paused | она уже была на паузе, повтор ничего не изменил |
resumed | пауза или плановая отмена снята |
already_active | снимать нечего: подписка не на паузе и не под отменой |
resumed_pending_charge | пауза снята, но оплаченный период уже кончился: доступ вернётся после ближайшего успешного списания; expiresAt пустой, accessPendingCharge: true |
После снятия паузы приходит вебхук subscription.resumed, после снятия отмены — entitlement.renewed. Поле cancelAtPeriodEnd показывает, осталась ли у подписки плановая отмена: пауза её не снимает.
Подписка, которая уже закончилась после отмены, отвечает на паузу и возобновление тем же 404, что и неизвестный покупатель: вернуть её можно только новой оплатой.
Портал: покупатель распоряжается сам
Второй путь — готовые экраны, где покупатель решает сам, а ваш сервер только выдаёт ссылку.
| Что нужно покупателю | Куда его отправить |
|---|---|
| отключить продление, включить обратно, взять скидку вместо отмены | наш экран портала — ссылка manage_link |
| сменить месячный тариф на годовой, заменить карту, посмотреть счета | платёжный портал продавца — POST /s2s/v1/subscription/portal |
Наш экран портала
Экран назначает владелец или администратор проекта: Настройки → Подключение приложения → Для разработчика, блок «Портал управления подпиской». Как его собрать — на дорожке владельца: «Магазин», шаг 4.
Ссылку на экран отдаёт поле manage_link в ответе GET /s2s/v1/entitlement?guid=. Она живёт 15 минут, поэтому запрашивайте право доступа заново прямо перед показом кнопки «Управлять подпиской» и открывайте ссылку сразу. manage_link: null значит, что портал не готов: экран не назначен, не опубликован или не привязан к домену.
Голый идентификатор покупателя пропуском не служит: портал открывается только по подписанной ссылке. Устаревшая ссылка — не тупик: портал сам попросит у покупателя почту и пришлёт письмо со ссылкой для входа.
То же письмо можно запросить из приложения, например кнопкой «Прислать ссылку на почту». Ключ здесь не нужен:
curl -X POST "https://api.subster.ai/public/handoff/portal-entry/request" \
-H "Content-Type: application/json" \
-d '{ "projectId": "<PROJECT_ID>", "email": "[email protected]" }'
# 204 — всегда, даже если такой почты нет
Письмо уходит, только если почта известна проекту и портал готов. Ограничения: 5 запросов за 15 минут с одного IP-адреса, письмо на одну почту — не чаще раза в 20 минут.
Платёжный портал продавца
curl -X POST "https://api.subster.ai/s2s/v1/subscription/portal" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "guid": "<GUID>", "priceId": "price_…",
"returnUrl": "https://app.example.com/account" }'
# { "url": "https://billing.stripe.com/session/…" }
Ссылка url одноразовая — откройте её сразу. returnUrl обязателен: это веб-адрес, куда портал вернёт покупателя; своя схема приложения (myapp://…) проверку не пройдёт. Пересчёт денег при смене тарифа делает платёжная система.
Портал открывается, пока у покупателя действует доступ по этой цене, в том числе после плановой отмены. Когда оплаченный период кончился, ответ — 404.
Списание с сохранённой карты
Разовое списание с карты, которую покупатель сохранил при оформлении подписки: доплата за расширенный доступ, пакет, годовой тариф. Покупатель в этот момент не участвует.
curl -X POST "https://api.subster.ai/s2s/v1/subscription/charge" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "guid": "<GUID>", "priceId": "price_upsell…",
"idempotencyKey": "order-2026-10-21-1" }'
# { "status": "succeeded", "paymentId": "pi_3abc…",
# "amountCents": 4900, "currency": "usd", "priceId": "price_upsell…" }
| Поле тела | Обязательное | Что делает |
|---|---|---|
guid | да | покупатель |
priceId | да | разовая цена каталога — она же сумма списания |
subscriptionId | нет | с какой подписки брать карту. Без него берётся действующая подписка; её нет — отказ no_active_subscription, и тогда укажите подписку явно: подойдёт и закончившаяся, карта остаётся у покупателя |
idempotencyKey | нет | ваш ключ, до 128 символов |
extendSubscriptionByDays | нет | 1–3650: продлить этим списанием подписку покупателя |
runId | нет | метка пачки: одно значение на все списания массовой операции, видно в журнале кабинета |
Ключ идемпотентности — главное в этом запросе. Повтор с тем же ключом не списывает второй раз, а отдаёт результат первой попытки. Без своего ключа сервер считает повтором списание той же цены тому же покупателю в пределах суток, поэтому для двух разных списаний одной цены подряд свой ключ нужен.
status в ответе | Что произошло |
|---|---|
succeeded | деньги списаны |
pending | платёж ушёл в обработку, деньги, вероятно, списаны; финальный исход досчитается сам |
requires_action | банк требует 3DS: денег не взяли, без покупателя не довести. Повтор с тем же ключом вернёт этот же ответ, новая попытка — только с новым ключом |
refunded | списание по этому ключу уже возвращено; для нового нужен новый ключ |
При requires_action поле paymentId может прийти пустым: не делайте его обязательным в своей записи.
Отказ карты и обрыв связи
Оба случая приходят не статусом, а ошибкой 400 с кодом invalid_request_error. Различает их только текст сообщения:
| Текст начинается с | Что случилось | Что делать |
|---|---|---|
| «Списание не прошло» | карта отклонила платёж, денег нет; придёт вебхук charge.failed | новая попытка — только с новым ключом |
| «Связь с платёжной системой прервалась» | исход неизвестен, деньги могли уйти | повторить тот же запрос с тем же ключом |
Сомневаетесь — повторяйте тот же запрос с тем же ключом: после отказа карты он вернёт тот же отказ, после обрыва доведёт первую попытку. Новый ключ при неизвестном исходе — это второе списание с карты.
То же правило — когда до вас не дошёл наш ответ. Без своего ключа повтор того же запроса в течение суток тоже найдёт первую попытку.
Через сутки после обрыва повтор отвечает charge_outcome_unknown: сверьте платёж в Stripe и списывайте заново с новым ключом, только если платежа не было.
Исход приезжает вебхуками charge.completed и charge.failed, отложенный платёж досчитывается ими же; 3DS сообщает charge.requires_action. При обрыве связи событие в этот момент не уходит. Дошёл запрос до платёжной системы — исход приедет позже теми же двумя событиями; не дошёл — события не будет вовсе, поэтому узнавайте исход повтором запроса, как сказано выше.
Узнать исход списания
Не дожидаясь вебхука, исход можно спросить по paymentId из ответа на списание:
curl "https://api.subster.ai/s2s/v1/subscription/charge/status?paymentId=pi_3abc…" \
-H "Authorization: Bearer sk_live_…"
# { "status": "succeeded", "paymentId": "pi_3abc…", "guid": "<GUID>",
# "priceId": "price_upsell…", "amountCents": 4900, "currency": "usd",
# "outcomeUnknown": false, "createdAt": "…", "updatedAt": "…" }
Статусы те же, что у списания, и ещё три: failed — списание не прошло; reserved — списание принято, но в платёжную систему ещё не ушло; refunding — идёт возврат. failed вместе с outcomeUnknown: true значит, что связь с платёжной системой оборвалась и деньги могли уйти.
Второй параметр запроса, idempotencyKey, по вашему ключу списание сейчас не находит и отвечает 404. Если ответ на списание не дошёл, повторите само списание с тем же ключом — выше.
Потолки списаний
Суммы ниже — в сотых главной единицы валюты: 10 000 = $100.00. Валюты без дробной части (JPY, KRW) приводятся к той же мерке автоматически. Настраиваемые потолки задаёт владелец в той же группе «Деньги покупателей», где включает списания.
| Предохранитель | По умолчанию | Потолок настройки | Отказ |
|---|---|---|---|
| сумма одной операции | 10 000 | 500 000 | charge_amount_above_limit |
| операций на покупателя за 24 часа | 3 | 10 | charge_daily_limit_exceeded |
| сумма на покупателя за 24 часа | 1 000 000 | не настраивается | charge_daily_limit_exceeded |
| операций и сумма на весь проект за 24 часа | 100 и 500 000 | 100 000 и 1 000 000 000 | charge_project_daily_limit_exceeded |
Возвращённое списание квоту занимает: возврат её не освобождает. Подтверждённый отказ карты и незавершённый 3DS квоту не тратят.
Списание с неизвестным исходом квоту занимает, пока исход неизвестен: деньги могли уйти, и обратного мы не утверждаем. Через сутки, если платёж у Stripe так и не нашёлся, квота освобождается.
Когда доплата продлевает подписку
Разовое списание подписку не продлевает: по умолчанию оно выдаёт покупателю отдельное право доступа на срок, заданный владельцем у этой цены. Срок не задан — права не будет, доступом распоряжаетесь вы сами.
Поле extendSubscriptionByDays меняет это поведение: успешное списание продлит действующую подписку на столько дней от большей из двух дат — конца оплаченного периода и текущего момента, — и следом уйдёт вебхук entitlement.renewed.
Вернуть деньги
Возврат только полный, суммы в теле нет.
curl -X POST "https://api.subster.ai/s2s/v1/subscription/refund" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "guid": "<GUID>", "subscriptionId": "sub_…" }'
# { "status": "refunded", "amountCents": null, "currency": null }
В теле всегда есть покупатель, а дальше ровно одно из двух. subscriptionId — полный возврат последнего платежа подписки: доступ отзывается сразу, подписка закрывается. paymentId — возврат разового списания, и только этот путь отдаёт сумму в ответе. На оба пути следом приходит вебхук refund.processed с суммой.
Сумму возврата по подписке ответ не знает никогда и отдаёт пустые поля. Денежную сверку стройте на вебхуке, а не на этом ответе.
Право, которое выдала разовая докупка, полный возврат снимает — придёт entitlement.revoked. Дни, которые списание добавило подписке, снимаются обратно ровно один раз, в том числе когда деньги вернули руками в дашборде платёжной системы; другие продления не трогаются. Об укороченном сроке событие не приходит — после такого возврата перечитайте право доступа.
| Ответ | Что означает |
|---|---|
already_refunded | это уже возвращено; второго возврата не будет |
refund_in_progress | тот же возврат разового списания прямо сейчас выполняется — дождитесь исхода |
409 с кодом invalid_request_error | возврат по подписке не начат: по ней уже идёт возврат, пауза или возобновление — повторите тот же запрос позже |
charge_in_progress | платёж ещё в обработке, повторите позже |
charge_outcome_unknown | исход этого списания неизвестен, деньги могли уйти: сверьте платёж в Stripe и, если он прошёл, верните его там |
refund_state_unknown | покрыл ли возврат последний платёж, проверить нечем — обращение покупателя по такому ответу закрывать нельзя |
Поле warning: "subscription_cancel_pending" в успешном ответе на возврат по подписке означает: деньги вернулись и доступ отозван, а отмену самой подписки платёжная система ещё не подтвердила. Это не отказ.
Коды отказа
Ветвите логику на машинный код, а не на тексте сообщения; единственное исключение — отказ карты и обрыв связи у списания, выше. Код лежит внутри error:
{ "error": { "type": "invalid_request_error",
"code": "charge_daily_limit_exceeded",
"message": "charge_daily_limit_exceeded: …" } }
На 404 код всегда not_found: единый ответ на любой промах, чтобы по нему нельзя было перебирать чужие идентификаторы.
| Код | Что делать |
|---|---|
charge_disabled · refund_disabled · subscription_manage_disabled | переключатель проекта выключен: владельцу — включить; ключ перевыпускать не нужно |
test_key_not_allowed | выпустить боевой ключ sk_live_… |
project_frozen | организация заморожена за долг или спор с банком: списания и возвраты остановлены, остальное работает. Снимает владелец — погасить долг или закрыть спор |
project_readonly | клиент деактивирован: остановлены ещё и отмена, пауза, возобновление и портал. Снимает только наша поддержка |
no_saved_payment_method | карты у покупателя нет: доплата возможна только обычной оплатой |
no_active_subscription | действующей подписки нет: передать subscriptionId явно |
price_not_one_time · price_has_no_amount | взять разовую цену каталога, у которой задана сумма |
idempotency_key_reused | ключ списания уже использован с другими параметрами: передать новый ключ либо повторить с прежней ценой |
charge_in_progress | по этому ключу списания уже идёт другой запрос или возврат: повторить тот же запрос позже, ключ не менять |
charge_outcome_unknown | исход прошлого списания неизвестен, деньги могли уйти: сверить платёж в Stripe. У списания — платежа не было, спишите с новым ключом; у возврата — платёж прошёл, верните его в Stripe |
charge_refunded_needs_new_key | без своего ключа запрос считается повтором сегодняшнего списания этой цены, а оно уже возвращено: передать свой idempotencyKey |
no_payment_to_refund | возвращать нечего: по подписке не нашлось оплаты либо разовое списание не прошло |
subscription_action_in_progress | над подпиской уже выполняется пауза или возобновление: повторить позже |
subscription_not_paused | возобновление: доступ истёк, а подписка не на паузе — снимать нечего |
Отказы по тарифу закрывают все запросы разом — о них «Диагностика», раздел «Дверь закрыта по тарифу». Полный перечень с HTTP-статусами — в Справочнике, раздел «Коды отказа».
Дальше
- Проверить право доступа — что приходит в ответе о доступе и как читать его поля.
- Вебхуки — события о списании, возврате, паузе и отзыве доступа, подпись доставки.
- Аутентификация — ключи, какое право нужно какому запросу, подпись серверных запросов.
- Диагностика — покупатель заплатил, а доступа в приложении нет.
- Рецепты — отмена подписки по кнопке в приложении целиком, готовым кодом на Node.