`POST /api/marketplace/purchases/{id}/cancel` — #1111 buyer self-service, #1124 with BOTH effects: end the subscription at the end of the period already paid for, or at once with the unused remainder refunded.
const url = 'https://example.com/api/marketplace/purchases/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/cancel';const options = { method: 'POST', headers: { cookie: 'supacloud_session=<supacloud_session>', 'Content-Type': 'application/json' }, body: '{"effect":"period_end"}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://example.com/api/marketplace/purchases/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/cancel \ --header 'Content-Type: application/json' \ --cookie supacloud_session=<supacloud_session> \ --data '{ "effect": "period_end" }'Workspace-scoped, not buyer-scoped. The license belongs to the workspace that paid for it, so any approved member acting in that workspace may end it — which also means the original buyer leaving the company does not strand the subscription with nobody able to cancel it.
The body is OPTIONAL and defaults to the period-end effect. A caller that sends nothing therefore gets the option that refunds no money and revokes no access, which is the only safe meaning for silence on this route.
Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Path Parameters
Section titled “Path Parameters”Request Bodyrequired
Section titled “Request Bodyrequired”Optional; omitted means period_end
#1124 — WHEN a subscription cancellation takes effect (Owner decision 2026-08-22: both options, deliberately chosen per action).
The whole body is optional and so is the field: an omitted effect resolves to
[SubscriptionCancelEffect::PeriodEnd], the option that refunds nothing and
destroys no access. That default is a safety property, not a convenience —
the immediate arm hands money back and ends the licence at once, so it must
never be what silence happens to mean.
object
WHEN a cancellation takes effect. The buyer chooses per action; the default is the conservative one.
PeriodEnd is #[default] deliberately, and the web surface pre-selects it
for the same reason (Owner decision 2026-08-22): an immediate cancellation
refunds money and destroys access in one click, so it must be a deliberate
second choice rather than what an omitted field happens to mean. A client that
sends no body at all therefore gets the harmless effect.
Responses
Section titled “Responses”A purchase record tying a buyer (user + workspace) to a specific item
version via a price row. All monetary amounts are in minor units (cents).
fee_policy_snapshot captures the fee settings at purchase time for audit.
object
Stripe’s truth: the end of the period that is currently paid for.
The single enforcement instant the license gate reads. None = perpetual
(a one-time purchase). See migration 350 for why it is derived from
current_period_end plus the grace window rather than equal to it.
The Stripe subscription (sub_...) this license is driven by. Also the
routing key that CLAIMS an invoice.* / customer.subscription.* event
for the marketplace instead of org billing.
Purchase lifecycle status (marketplace_purchases.status CHECK). Runtime
stays String; the spec narrows it to the closed [PurchaseStatusSpec].
Example
{ "status": "pending"}Not a subscription purchase, or the provider refused
The canonical JSON body of every error response — the single source of truth
the frontend binds to. Every AppError serializes as this exact shape, and
the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is
what the frontend error schema is generated from, so there is no hand-written
error schema on either end.
object
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}Authentication required
The canonical JSON body of every error response — the single source of truth
the frontend binds to. Every AppError serializes as this exact shape, and
the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is
what the frontend error schema is generated from, so there is no hand-written
error schema on either end.
object
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}Purchase belongs to another workspace
The canonical JSON body of every error response — the single source of truth
the frontend binds to. Every AppError serializes as this exact shape, and
the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is
what the frontend error schema is generated from, so there is no hand-written
error schema on either end.
object
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}Purchase not found
The canonical JSON body of every error response — the single source of truth
the frontend binds to. Every AppError serializes as this exact shape, and
the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is
what the frontend error schema is generated from, so there is no hand-written
error schema on either end.
object
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}Not in a cancellable state
The canonical JSON body of every error response — the single source of truth
the frontend binds to. Every AppError serializes as this exact shape, and
the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is
what the frontend error schema is generated from, so there is no hand-written
error schema on either end.
object
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}Structured server error
The canonical JSON body of every error response — the single source of truth
the frontend binds to. Every AppError serializes as this exact shape, and
the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is
what the frontend error schema is generated from, so there is no hand-written
error schema on either end.
object
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}