S Subster

Деньги из приложения

Вы дадите покупателю отменить, приостановить и вернуть подписку прямо в приложении, а своему серверу — брать доплату с сохранённой карты и возвращать деньги.

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

Перед началом

Денежные права ключ получает отдельно: общее право *, с которым ключ выпускается по умолчанию, их не покрывает. Каждое открывается своим переключателем в кабинете владельца: Настройки → Платежи → Правила и деньги, группа «Деньги покупателей». Доступа в кабинет у вас нет — попросите владельца проекта. Кто кого зовёт и какие бывают роли, написано на дорожке владельца: «Проект и команда» и «Передача разработчику».

Что делаетеЗапросПраво ключаПереключатель владельца
Отменить, приостановить, возобновить подписку, открыть платёжный порталPOST /s2s/v1/subscription/cancel, /pause, /resume, /portalsubscription:manageУправление подписками через API
Списать с сохранённой карты, узнать исход списанияPOST /s2s/v1/subscription/charge, GET /s2s/v1/subscription/charge/statusbilling:chargeСписания через API
Вернуть деньгиPOST /s2s/v1/subscription/refundbilling: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 000500 000charge_amount_above_limit
операций на покупателя за 24 часа310charge_daily_limit_exceeded
сумма на покупателя за 24 часа1 000 000не настраиваетсяcharge_daily_limit_exceeded
операций и сумма на весь проект за 24 часа100 и 500 000100 000 и 1 000 000 000charge_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-статусами — в Справочнике, раздел «Коды отказа».

Дальше