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 do | Request | Key scope | Owner's toggle |
|---|---|---|---|
| Cancel, pause, resume a subscription, open the billing portal | POST /s2s/v1/subscription/cancel, /pause, /resume, /portal | subscription:manage | Subscription management via API |
| Charge the saved card, get the charge outcome | POST /s2s/v1/subscription/charge, GET /s2s/v1/subscription/charge/status | billing:charge | Charges via API |
| Refund money | POST /s2s/v1/subscription/refund | billing:refund | Refunds 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 response | What happened |
|---|---|
paused | the subscription is paused |
already_paused | it was already paused; the repeat changed nothing |
resumed | the pause or scheduled cancellation is lifted |
already_active | nothing to lift: the subscription is neither paused nor being cancelled |
resumed_pending_charge | the 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 needs | Where to send them |
|---|---|
| turn renewal off, back on, take a discount instead of cancelling | our portal screen — the manage_link link |
| switch a monthly plan to yearly, replace the card, see invoices | the 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 field | Required | What it does |
|---|---|---|
guid | yes | the customer |
priceId | yes | a one-time catalogue price — it is also the charge amount |
subscriptionId | no | which 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 |
idempotencyKey | no | your key, up to 128 characters |
extendSubscriptionByDays | no | 1–3650: extend the customer's subscription with this charge |
runId | no | run 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 response | What happened |
|---|---|
succeeded | money charged |
pending | the payment is processing, money is probably charged; the final outcome settles by itself |
requires_action | the 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 |
refunded | the 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 with | What happened | What to do |
|---|---|---|
| “Списание не прошло” | the card declined, no money taken; the charge.failed webhook arrives | a new attempt only with a new key |
| “Связь с платёжной системой прервалась” | outcome unknown, money may have left | repeat 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.
| Safeguard | Default | Setting ceiling | Refusal |
|---|---|---|---|
| amount of one operation | 10 000 | 500 000 | charge_amount_above_limit |
| operations per customer in 24 hours | 3 | 10 | charge_daily_limit_exceeded |
| amount per customer in 24 hours | 1 000 000 | not adjustable | charge_daily_limit_exceeded |
| operations and amount for the whole project in 24 hours | 100 and 500 000 | 100 000 and 1 000 000 000 | charge_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.
| Response | What it means |
|---|---|
already_refunded | already refunded; there will be no second refund |
refund_in_progress | the same one-time charge refund is running right now — wait for the outcome |
409 with invalid_request_error | the subscription refund has not started: a refund, pause or resume is already running on it — repeat the same request later |
charge_in_progress | the payment is still processing, repeat later |
charge_outcome_unknown | this charge's outcome is unknown, money may have left: check the payment in Stripe and, if it went through, refund it there |
refund_state_unknown | there 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.
| Code | What to do |
|---|---|
charge_disabled · refund_disabled · subscription_manage_disabled | the project toggle is off: the owner turns it on; no key reissue needed |
test_key_not_allowed | issue a live key sk_live_… |
project_frozen | the 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_readonly | the client is deactivated: cancel, pause, resume and the portal are stopped too. Only our support lifts it |
no_saved_payment_method | the customer has no card: an extra payment is possible only through regular checkout |
no_active_subscription | no active subscription: pass subscriptionId explicitly |
price_not_one_time · price_has_no_amount | take a one-time catalogue price with an amount set |
idempotency_key_reused | the charge key was already used with other parameters: pass a new key or repeat with the previous price |
charge_in_progress | another request or refund is already running under this charge key: repeat the same request later, do not change the key |
charge_outcome_unknown | the 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_key | without 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_refund | nothing to refund: no payment found for the subscription, or the one-time charge failed |
subscription_action_in_progress | a pause or resume is already running on the subscription: repeat later |
subscription_not_paused | resume: 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
- Check the entitlement — what the access answer contains and how to read its fields.
- Webhooks — events about charges, refunds, pauses and access revocation, delivery signing.
- Authentication — keys, which scope each request needs, server request signing.
- Troubleshooting — the customer paid, but the app shows no access.
- Recipes — subscription cancellation from an app button in full, as ready-made Node code.