S Subster

Money from the app

You will let the customer cancel, pause and resume a subscription right in the app, and let your server take extra charges from the saved card and refund money.

The customer pays on a web page but comes to the app to cancel renewal, pause, pay extra or get a refund. The card stays with us, and requests from your server do all this. They carry no amount: a refund is always full, and a charge takes the price from the project catalogue.

Before you start

The key gets money scopes separately: the general * scope a key is issued with by default does not cover them. Each is opened by its own toggle in the owner's cabinet: Settings → Payments → Rules and money, group “Customer money”. If you have no cabinet access, ask the project owner. Who invites whom and which roles exist is on the owner's track: “Project and team” and “Hand off to a developer”.

What you doRequestKey scopeOwner's toggle
Cancel, pause, resume a subscription, open the billing portalPOST /s2s/v1/subscription/cancel, /pause, /resume, /portalsubscription:manageSubscription management via API
Charge the saved card, get the charge outcomePOST /s2s/v1/subscription/charge, GET /s2s/v1/subscription/charge/statusbilling:chargeCharges via API
Refund moneyPOST /s2s/v1/subscription/refundbilling:refundRefunds via API

Order does not matter: a turned-on toggle adds the scope to already issued keys too; no reissue needed. A turned-off toggle is an emergency brake for the whole project: the scope is removed from all keys, and any key, a leaked one included, gets 403 with charge_disabled, refund_disabled or subscription_manage_disabled.

A test key moves no money, whatever the toggles. Reading outcomes and managing subscriptions with a test key works.

The “Customer money” group is part of the paid app connection: on the free plan the cabinet shows an offer to upgrade to Pro instead of toggles. Until a payment system is connected in the project, the group is marked “locked” — the owner connects a payment system first.

Cancel a subscription from the app

The native “Cancel subscription” button calls your server, and it calls our request. The price in the body picks the subscription: a customer may have several, and we do not cancel at random.

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" }

The cancellation is scheduled: there is no next charge, and the customer uses up the paid time — access lives until expiresAt. Show exactly that in the app: “Subscription active until <date>, renewal off”.

The state is also visible in the entitlement answer: the grant stays active but comes with will_renew: false. When the period ends, the entitlement.revoked webhook arrives — remove paid access on your side on it.

Repeating the request is safe: an already cancelled subscription answers already_canceled. An unknown customer, another project and no active subscription with this price all give the same 404 — the answer cannot be used to enumerate other people's identifiers.

Cancellation does not return money — refunds do that. A resume lifts the cancellation before the period ends.

Pause and resume

A pause stops payment but does not take away what was paid: the customer no longer pays, and access lasts to the end of the paid period and is not renewed. The body is the same as for cancellation.

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" }

A pause has no term: it stays until you lift it. In the entitlement answer a paused subscription comes with will_renew: false, and you get the subscription.paused webhook.

POST /s2s/v1/subscription/resume with the same body lifts the pause. The same request also lifts a scheduled cancellation while the paid period lasts. It creates no new payment.

status in the responseWhat happened
pausedthe subscription is paused
already_pausedit was already paused; the repeat changed nothing
resumedthe pause or scheduled cancellation is lifted
already_activenothing to lift: the subscription is neither paused nor being cancelled
resumed_pending_chargethe pause is lifted, but the paid period has already ended: access returns after the next successful charge; expiresAt is empty, accessPendingCharge: true

After a pause is lifted, the subscription.resumed webhook arrives; after a cancellation is lifted, entitlement.renewed. The cancelAtPeriodEnd field shows whether the subscription still has a scheduled cancellation: a pause does not lift it.

A subscription that already ended after cancellation answers pause and resume with the same 404 as an unknown customer: only a new payment brings it back.

Portal: the customer decides

The second way is ready screens where the customer decides, and your server only issues the link.

What the customer needsWhere to send them
turn renewal off, back on, take a discount instead of cancellingour portal screen — the manage_link link
switch a monthly plan to yearly, replace the card, see invoicesthe provider billing portal — POST /s2s/v1/subscription/portal

Our portal screen

The project owner or admin assigns the screen: Settings → App connection → For developers, the “Subscription management portal” block. How to build it is on the owner's track: “Store”, step 4.

The manage_link field in the GET /s2s/v1/entitlement?guid= answer gives the link to the screen. It lives 15 minutes, so request the entitlement again right before showing the “Manage subscription” button and open the link at once. manage_link: null means the portal is not ready: the screen is not assigned, not published or not bound to a domain.

A bare buyer identifier is not a pass: the portal opens only by a signed link. An outdated link is not a dead end: the portal asks the customer for an email and sends a sign-in link.

The same email can be requested from the app, for example with a “Send me a link” button. No key needed here:

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 — всегда, даже если такой почты нет

The email goes out only if the project knows the address and the portal is ready. Limits: 5 requests per 15 minutes from one IP address, an email to one address at most once per 20 minutes.

Provider billing portal

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/…" }

The url link is single-use — open it at once. returnUrl is required: it is the web address the portal returns the customer to; the app's own scheme (myapp://…) fails the check. The payment system recalculates money on a plan change.

The portal opens while the customer has access under this price, including after a scheduled cancellation. Once the paid period ends, the answer is 404.

Saved-card charge

A one-time charge on the card the customer saved when subscribing: an extra for expanded access, a bundle, a yearly plan. The customer does not take part at that moment.

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…" }
Body fieldRequiredWhat it does
guidyesthe customer
priceIdyesa one-time catalogue price — it is also the charge amount
subscriptionIdnowhich subscription's card to use. Without it, the active subscription is used; if there is none, the refusal is no_active_subscription, and then name the subscription explicitly: an ended one works too, the card stays with the customer
idempotencyKeynoyour key, up to 128 characters
extendSubscriptionByDaysno1–3650: extend the customer's subscription with this charge
runIdnorun id: one value for all charges of a bulk operation, visible in the cabinet log

The idempotency key is the core of this request. A repeat with the same key does not charge twice but returns the first attempt's result. Without your own key, the server treats a charge of the same price to the same customer within a day as a repeat, so two different charges of one price in a row need your own key.

status in the responseWhat happened
succeededmoney charged
pendingthe payment is processing, money is probably charged; the final outcome settles by itself
requires_actionthe bank requires 3DS: no money taken, cannot complete without the customer. A repeat with the same key returns this same answer; a new attempt only with a new key
refundedthe charge under this key is already refunded; a new one needs a new key

With requires_action the paymentId field may be empty: do not make it required in your record.

Card decline and lost connection

Both come not as a status but as a 400 error with invalid_request_error. Only the message text tells them apart:

Text starts withWhat happenedWhat to do
“Списание не прошло”the card declined, no money taken; the charge.failed webhook arrivesa new attempt only with a new key
“Связь с платёжной системой прервалась”outcome unknown, money may have leftrepeat the same request with the same key

If in doubt, repeat the same request with the same key: after a decline it returns the same decline, after a lost connection it completes the first attempt. A new key with an unknown outcome is a second charge on the card.

The same rule applies when our answer did not reach you. Without your own key, repeating the same request within a day also finds the first attempt.

A day after a lost connection a repeat answers charge_outcome_unknown: check the payment in Stripe and charge again with a new key only if there was no payment.

The outcome arrives by the charge.completed and charge.failed webhooks; an async payment settles through them too; 3DS is reported by charge.requires_action. On a lost connection no event goes out at that moment. If the request reached the payment system, the outcome comes later by the same two events; if not, there will be no event at all, so learn the outcome by repeating the request, as above.

Get the charge outcome

Without waiting for a webhook, you can ask for the outcome by the paymentId from the charge answer:

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": "…" }

The statuses are the same as for a charge, plus three: failed — the charge did not go through; reserved — the charge is accepted but not yet sent to the payment system; refunding — a refund is in progress. failed together with outcomeUnknown: true means the connection to the payment system broke and money may have left.

The request's second parameter, idempotencyKey, currently does not find a charge by your key and answers 404. If the charge answer did not reach you, repeat the charge itself with the same key — see above.

Charge limits

Amounts below are in hundredths of the main currency unit: 10 000 = $100.00. Currencies without a fractional part (JPY, KRW) are converted to the same measure automatically. The owner sets the adjustable limits in the same “Customer money” group where charges are turned on.

SafeguardDefaultSetting ceilingRefusal
amount of one operation10 000500 000charge_amount_above_limit
operations per customer in 24 hours310charge_daily_limit_exceeded
amount per customer in 24 hours1 000 000not adjustablecharge_daily_limit_exceeded
operations and amount for the whole project in 24 hours100 and 500 000100 000 and 1 000 000 000charge_project_daily_limit_exceeded

A refunded charge still uses quota: the refund does not free it. A confirmed card decline and unfinished 3DS do not use quota.

A charge with an unknown outcome uses quota while the outcome is unknown: money may have left, and we do not claim otherwise. After a day, if Stripe still has no such payment, the quota is freed.

When an extra charge extends the subscription

A one-time charge does not extend the subscription: by default it gives the customer a separate entitlement for the term the owner set on this price. No term set — no entitlement; you manage access yourself.

The extendSubscriptionByDays field changes this: a successful charge extends the active subscription by that many days from the later of two dates — the end of the paid period and now — and then the entitlement.renewed webhook goes out.

Refund money

Refunds are full only; the body has no amount.

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 }

The body always has the customer, then exactly one of two. subscriptionId — a full refund of the subscription's last payment: access is revoked at once, the subscription closes. paymentId — a refund of a one-time charge, and only this path returns the amount in the answer. Both paths are followed by the refund.processed webhook with the amount.

The answer never knows the amount of a subscription refund and returns empty fields. Build money reconciliation on the webhook, not on this answer.

A full refund removes the entitlement a one-time add-on granted — entitlement.revoked arrives. The days a charge added to a subscription are removed exactly once, including when money was refunded by hand in the payment system dashboard; other extensions are not touched. No event comes about the shortened term — re-read the entitlement after such a refund.

ResponseWhat it means
already_refundedalready refunded; there will be no second refund
refund_in_progressthe same one-time charge refund is running right now — wait for the outcome
409 with invalid_request_errorthe subscription refund has not started: a refund, pause or resume is already running on it — repeat the same request later
charge_in_progressthe payment is still processing, repeat later
charge_outcome_unknownthis charge's outcome is unknown, money may have left: check the payment in Stripe and, if it went through, refund it there
refund_state_unknownthere is no way to check whether the refund covered the last payment — do not close the customer's request on this answer

The warning: "subscription_cancel_pending" field in a successful subscription refund answer means: the money is back and access revoked, but the payment system has not yet confirmed cancelling the subscription itself. It is not a refusal.

Refusal codes

Branch on the machine code, not the message text; the only exception is card decline and lost connection on a charge, above. The code is inside error:

{ "error": { "type": "invalid_request_error",
             "code": "charge_daily_limit_exceeded",
             "message": "charge_daily_limit_exceeded: …" } }

On 404 the code is always not_found: one answer for any miss, so it cannot be used to enumerate other people's identifiers.

CodeWhat to do
charge_disabled · refund_disabled · subscription_manage_disabledthe project toggle is off: the owner turns it on; no key reissue needed
test_key_not_allowedissue a live key sk_live_…
project_frozenthe organization is frozen for debt or a bank dispute: charges and refunds are stopped, the rest works. The owner lifts it — pay the debt or close the dispute
project_readonlythe client is deactivated: cancel, pause, resume and the portal are stopped too. Only our support lifts it
no_saved_payment_methodthe customer has no card: an extra payment is possible only through regular checkout
no_active_subscriptionno active subscription: pass subscriptionId explicitly
price_not_one_time · price_has_no_amounttake a one-time catalogue price with an amount set
idempotency_key_reusedthe charge key was already used with other parameters: pass a new key or repeat with the previous price
charge_in_progressanother request or refund is already running under this charge key: repeat the same request later, do not change the key
charge_outcome_unknownthe previous charge's outcome is unknown, money may have left: check the payment in Stripe. For a charge — if there was no payment, charge with a new key; for a refund — if the payment went through, refund it in Stripe
charge_refunded_needs_new_keywithout your own key the request counts as a repeat of today's charge of this price, and that one is already refunded: pass your own idempotencyKey
no_payment_to_refundnothing to refund: no payment found for the subscription, or the one-time charge failed
subscription_action_in_progressa pause or resume is already running on the subscription: repeat later
subscription_not_pausedresume: access has expired and the subscription is not paused — nothing to lift

Plan refusals close all requests at once — see “Troubleshooting”, section “Closed by the plan”. The full list with HTTP statuses is in the Reference, section “Refusal codes”.

Next