Switch plan or change the quantity
The portal.subscriptions.update operation: POST /api/v1/portal/me/subscriptions/{subscriptionId} in the REST API and the portal_subscriptions_update tool in the MCP server.
/api/v1/portal/me/subscriptions/{subscriptionId}Switches one item of the signed-in customer's subscription to another plan, or changes its quantity, as
portal.subscriptions.preview_change showed: an upgrade applies now, its prorated invoice is issued and charged
to the subscription's card (or sent, for a subscription paid by bank transfer); a downgrade is scheduled for the
end of the period (scheduledChange). The brand's portal settings decide what may change, and its billing
settings how a failed payment is handled. Send the preview's version as expectedVersion. Emits
subscription.updated. Customer portal only (portal:write).
At a glance
- REST:
POST /api/v1/portal/me/subscriptions/{subscriptionId} - MCP tool:
portal_subscriptions_update - Operation ID:
portal.subscriptions.update - Scope: Brand
- Permission:
portal:write - Risk tier:
financial - Dry run: Supported (send
Ownrecord-Dry-Run: true) - Idempotency:
Idempotency-Keyrequired - Modes: Test and live
- Authentication: A customer signed in to the customer portal (API keys can't call it)
- Since: M9
Request
Path parameters
| Field | Type | Description |
|---|---|---|
subscriptionId | sub_… ID | One of your subscriptions (sub_…). |
Body
A JSON object (Content-Type: application/json):
| Field | Type | Required | Description |
|---|---|---|---|
itemId | si_… ID | Yes | The subscription item to change (si_…). |
priceId | price_… ID | No | Switch the item to this price: one the brand's plan-change matrix allows from its price. |
quantity | integer (1 to 1000000) | No | The item's new quantity (seats), when the brand lets customers change it. |
expectedVersion | integer (at least 1) | No | The subscription's version you saw (the preview's); if it changed meanwhile, version_conflict. |
Response
200 OK with a JSON object:
| Field | Type | Description |
|---|---|---|
id | sub_… ID | |
object | "portal_subscription" | |
status | one of incomplete, incomplete_expired, trialing, active, past_due, unpaid, paused, canceled | |
currency | string | |
items | array of object | |
items[].priceId | price_… ID | |
items[].productId | prod_… ID | |
items[].productName | string | |
items[].priceName | string or null | The price's nickname, e.g. “Pro, monthly”. |
items[].interval | object | |
items[].interval.interval | one of day, week, month, year | |
items[].interval.intervalCount | integer | |
items[].usageType | one of licensed, metered | |
items[].unitAmount | money or null | A per-unit price's amount per unit and period (null for tiered and metered prices). |
items[].taxBehavior | one of inclusive, exclusive | exclusive: tax is added on the invoice. |
items[].id | si_… ID | |
items[].quantity | integer | |
items[].amount | money or null | The item's full-period amount at its quantity, before discounts (null for metered items). |
collectionMethod | one of charge_automatically, send_invoice | charge_automatically (your card is charged) or send_invoice (paid by bank transfer). |
paymentMethodId | pm_… ID or null | The card this subscription charges (null: your default card). |
currentPeriodStart | date-time | |
currentPeriodEnd | date-time | |
trialEnd | date-time or null | |
cancelAtPeriodEnd | boolean | |
cancelAt | date-time or null | |
canceledAt | date-time or null | |
endedAt | date-time or null | |
cancellation | object or null | |
discounts | array of object | |
discounts[].id | disc_… ID | |
discounts[].name | string | The coupon's name. |
discounts[].percentOffPpm | string or null | Parts per million as a string (20% = "200000"). |
discounts[].amountOff | money or null | |
discounts[].start | date-time | |
discounts[].end | date-time or null | When it stops applying (null: for as long as the subscription runs). |
scheduledChange | object or null | A change that applies at effectiveAt (a downgrade waits for the period end). |
actions | object | What you can do with this subscription now, as the brand's portal settings allow. |
actions.switch_plan | boolean | |
actions.change_quantity | boolean | |
actions.apply_promotion_code | boolean | |
actions.cancel | boolean | |
actions.reactivate | boolean | |
actions.change_payment_method | boolean | |
cancelBehavior | one of period_end, immediate | How cancelling here ends the subscription. |
collectCancelReason | boolean | Cancelling asks why (optional answers). |
version | integer | Send it back as expectedVersion to change what you saw. |
createdAt | date-time |
Examples
Move up to Pro now
POST /api/v1/portal/me/subscriptions/sub_01m4tx5nj8emwbyf7y9vcf48c0
Authorization: Bearer or_test_…
Idempotency-Key: 3f1c9a52-7d0e-4b8f-9a61-0c2d5e8b7f14
Content-Type: application/json
{
"itemId": "si_01m4tx5nj8emwbyf7y9vcf48c1",
"priceId": "price_01m4tx5nj8emwbyf7y9vcf48c3",
"expectedVersion": 4
}
Response (200):
{
"id": "sub_01m4tx5nj8emwbyf7y9vcf48c0",
"object": "portal_subscription",
"status": "active",
"currency": "EUR",
"items": [
{
"priceId": "price_01m4tx5nj8emwbyf7y9vcf48c3",
"productId": "prod_01m4tx5nj8emwbyf7y9vcf48c5",
"productName": "Genno Pro",
"priceName": "Pro, monthly",
"interval": {
"interval": "month",
"intervalCount": 1
},
"usageType": "licensed",
"unitAmount": {
"amount": "29000000",
"currency": "EUR"
},
"taxBehavior": "inclusive",
"id": "si_01m4tx5nj8emwbyf7y9vcf48c1",
"quantity": 1,
"amount": {
"amount": "29000000",
"currency": "EUR"
}
}
],
"collectionMethod": "charge_automatically",
"paymentMethodId": null,
"currentPeriodStart": "2026-10-20T09:00:00.000Z",
"currentPeriodEnd": "2026-11-20T10:00:00.000Z",
"trialEnd": null,
"cancelAtPeriodEnd": false,
"cancelAt": null,
"canceledAt": null,
"endedAt": null,
"cancellation": null,
"discounts": [],
"scheduledChange": null,
"actions": {
"switch_plan": true,
"change_quantity": false,
"apply_promotion_code": true,
"cancel": true,
"reactivate": false,
"change_payment_method": true
},
"cancelBehavior": "period_end",
"collectCancelReason": true,
"version": 5,
"createdAt": "2026-09-20T09:00:00.000Z"
}
Errors
| Code | HTTP status | When | What to do |
|---|---|---|---|
validation_failed | 400 | A field is malformed, or neither priceId nor quantity was sent. | Fix the fields listed in details.issues and send the request again. |
resource_not_found | 404 | You have no such subscription (another customer's reads the same). | Check the ID and that your credentials are scoped to the right organization, entity, brand and mode. |
subscription_item_not_found | 404 | itemId isn't one of the subscription's items. | Read the subscription and use one of its items[].id (si_…); to add a new price, leave out id. |
portal_action_not_allowed | 403 | The brand's portal settings or the subscription's state don't allow this change: plan switches need the brand's plan-change matrix to list the target, quantity changes need the brand's permission and a licensed item (details.action). | The brand's portal settings don't allow it for this subscription now (details.action): contact the brand. |
subscription_change_invalid | 400 | The change can't be made now (for example a change already waits for its invoice to be paid). | Check details.reason and adjust the request. |
subscription_status_invalid | 409 | The subscription can't change in its status. | Check details.status: items change only while trialing, active or past due; pausing needs an active subscription and resuming a paused one; reactivating needs a cancellation scheduled for the period end; canceled and expired subscriptions don't change. |
subscription_price_invalid | 400 | The new plan's price is archived. | Use active recurring prices of one currency whose intervals are whole multiples of each other (monthly with yearly, weekly with 2-weekly). details.reason says what's wrong and param names the item. |
jurisdiction_blocked | 422 | The brand can't sell in your country now (an invoice due now can't be issued). | This jurisdiction isn't covered by usable tax knowledge for this seller. Contact support if you need to sell there. |
registration_required | 422 | The brand can't invoice your country yet (an invoice due now can't be issued). | Register the entity for the regime named in details.regime (see its prerequisites and deadline), or arm the jurisdiction once the prerequisites are done. The customer can join the waitlist meanwhile. |
version_conflict | 409 | The subscription changed since you saw it (expectedVersion); look again. | Fetch the object again and retry the change. |
livemode_not_allowed | 403 | The portal session is in the other mode than the request. | Use test mode, or credentials for the target's mode. Developers need live access granted by an organization owner or admin. |
permission_denied | 403 | The caller isn't a customer signed in to this brand's portal (operators and API keys use the subscription and wallet operations). | Ask an organization owner or admin for a role or scope that allows this action. |
It can also return these common errors: api_version_unsupported, invalid_json, unsupported_media_type, authentication_required, api_key_invalid, scope_required, idempotency_key_required, idempotency_key_reused, idempotency_in_progress, rate_limited, internal_error.