Skip to content
Select themeSelect language

`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.

POST
/api/marketplace/purchases/{id}/cancel
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.

id
required
string format: uuid

Optional; omitted means period_end

Media typeapplication/json

#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
effect

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.

string
Allowed values: period_end immediate
Media typeapplication/json

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
amount_minor
required
integer format: int32
buyer_user_id
required
string format: uuid
buyer_workspace_id
required
string format: uuid
cancel_at_period_end
required
boolean
canceled_at
string | null format: date-time
created_at
required
string format: date-time
currency
required
string
current_period_end

Stripe’s truth: the end of the period that is currently paid for.

string | null format: date-time
disputed_at
string | null format: date-time
fee_policy_snapshot
required
id
required
string format: uuid
item_version_id
required
string format: uuid
license_expires_at

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.

string | null format: date-time
platform_fee_minor
required
integer format: int32
price_id
required
string format: uuid
provider_checkout_id
string | null
provider_fee_minor
required
integer format: int32
provider_payment_id
string | null
provider_subscription_id

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.

string | null
purchased_at
string | null format: date-time
refunded_at
string | null format: date-time
seller_profile_id
required
string format: uuid
status
required

Purchase lifecycle status (marketplace_purchases.status CHECK). Runtime stays String; the spec narrows it to the closed [PurchaseStatusSpec].

string
Allowed values: pending active refunded partially_refunded disputed revoked past_due suspended canceled
updated_at
required
string format: date-time
Example
{
"status": "pending"
}

Not a subscription purchase, or the provider refused

Media typeapplication/json

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
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}

Authentication required

Media typeapplication/json

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
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}

Purchase belongs to another workspace

Media typeapplication/json

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
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}

Purchase not found

Media typeapplication/json

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
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}

Not in a cancellable state

Media typeapplication/json

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
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}

Structured server error

Media typeapplication/json

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
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}