BillKit/DocsConsole
Subscriptions

Change a subscription's plan

Moves a subscription to a different price. BillKit prices the switch, charges the difference against the existing mandate, and applies the new plan when that payment settles.

POST/v1/subscriptions/{subscription_id}/update
Operation update_subscription·Scope subscriptions:write·OpenAPI v1.1.0

Parameters

ParameterDescription
subscription_idstring · pathrequiredId of the subscription, which starts sub_.
target_price_idstringrequiredId of the price to move the subscription to. It and its product must be active, in the subscription's currency and not metered, and it must differ from the current price.

Returns

The subscription as it stands right now, which is still on the old price. The switch is asynchronous: this call only mints the charge. When it settles you get subscription.updated, subscription.plan_changed and payment.succeeded. If it fails you get payment.failed and the plan does not change.

The price of the switch is a credit for the unused part of the current period, subtracted from the full new price. Preview it first if you want to show the customer the number before they commit.

Two changes are refused. An upgrade whose net charge would be under 100 minor units is too close to the renewal, so it answers 409 state_conflict, and waiting bills the new rate anyway. A downgrade, where the credit exceeds the cheaper price, answers 400 parameter_invalid with reason: “plan_change_not_supported”: cancel at period end and start the cheaper plan with a new checkout when it lapses.

The subscription must be active or past_due (a trialing one is refused with 409 state_conflict) and auto_renew with a mandate, the target price and its product must be active, the currency must match, and neither price may be metered.

While a renewal charge for the subscription is still settling the call returns 409 operation_pending, with the time to retry in the message. A past_due subscription returns 409 subscription_past_due: the unpaid period has to be paid first, through reauthorize_payment_method. If a chargeback or a decline marked the stored payment method as unusable, the call returns 409 state_conflict with reason: “no_active_mandate” until the customer replaces it. If Mollie refuses to create the proration charge, the call returns 402 with reason: “payment_refused”, nothing is charged and the plan does not change. If Mollie does not answer, the call returns 502 and the outcome is unknown: retry with the same Idempotency-Key.

Test it

Change the plan, then advance a test clock past the period end and read the invoice that falls out.

Errors

StatusCause
400The request was refused (type: invalid_request_error): a required parameter is missing (parameter_missing), one failed validation (parameter_invalid) or is not one the operation takes (parameter_unknown), and param names it.
401No credential, or one that does not authenticate.
402The account is suspended (tenant_suspended), or the payment provider refused a charge or refund the call made (type: payment_error, code: payment_refused).
403Authenticated, but not allowed: missing scope (insufficient_scope), a key of the wrong mode (mode_mismatch), or a refused origin. Needs the subscriptions:write scope.
404No such object in this account and mode.
409Conflicts with the object's current state (state_conflict, or a more specific code), or an operation still in flight (operation_pending).
413The request body is larger than the API accepts.
429Rate limited. Retry-After says when to try again.
500Something failed on BillKit's side (type: api_error). Retry with the same Idempotency-Key.
502The payment provider failed or did not answer.
503Temporarily unable to serve. reason says whether the request was attempted.
Request · Node.js
const subscriptionId = "sub_9XKp2vQ1";

// Price the switch first, so the customer sees the number.
const quote = await client.subscriptions.previewUpdate(subscriptionId, {
  target_price_id: "price_pro_yearly",
});

const subscription = await client.subscriptions.update(
  subscriptionId,
  { target_price_id: "price_pro_yearly" },
  { idempotencyKey: `swap-${subscriptionId}-to-yearly` },
);

// Still on the old price here. The switch lands when the proration
// charge settles; wait for subscription.updated.
Request · Python
subscription_id = "sub_9XKp2vQ1"

# Price the switch first, so the customer sees the number.
quote = client.subscriptions.preview_update(
    subscription_id,
    target_price_id="price_pro_yearly",
)

subscription = client.subscriptions.update(
    subscription_id,
    target_price_id="price_pro_yearly",
    idempotency_key=f"swap-{subscription_id}-to-yearly",
)

# Still on the old price here. The switch lands when the proration
# charge settles; wait for subscription.updated.
Request · PHP
$subscriptionId = 'sub_9XKp2vQ1';

// Price the switch first, so the customer sees the number.
$quote = $client->subscriptions->previewUpdate($subscriptionId, [
    'target_price_id' => 'price_pro_yearly',
]);

$subscription = $client->subscriptions->update(
    $subscriptionId,
    ['target_price_id' => 'price_pro_yearly'],
    ['idempotency_key' => "swap-{$subscriptionId}-to-yearly"],
);

// Still on the old price here. The switch lands when the proration
// charge settles; wait for subscription.updated.
Request · Laravel
$subscription = $user->subscription();

// Price the switch first, so the customer sees the number.
$quote = $subscription->previewSwap('price_pro_yearly');

$subscription->swap('price_pro_yearly');

// Still on the old price here. The webhook controller updates the
// local row when the proration charge settles.
Request · React
// Plan changes are a server call: they move money against a stored
// mandate. Do the swap in your own API and let the browser read the
// result back.
await fetch("/api/billing/swap", {
  method: "POST",
  body: JSON.stringify({ targetPriceId: "price_pro_yearly" }),
});
Request · curl
curl -X POST https://api.billkit.eu/v1/subscriptions/sub_9XKp2vQ1/update \
  -H "Authorization: Bearer $BILLKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target_price_id":"price_9XKp2vQ1"}'
Response · 200 OK
{
  "cancel_at_period_end": false,
  "canceled_at": 1789392000,
  "cancellation_reason": "refund_issued",
  "created": 1789392000,
  "current_period_end": 0,
  "current_period_start": 0,
  "customer": {
    "country_code": "NL",
    "email": "ada@example.com",
    "id": "obj_9XKp2vQ1",
    "name": "Ada Lovelace",
    "object": "customer.summary"
  },
  "customer_id": "cus_9XKp2vQ1",
  "discount": {
    "coupon_id": "coup_9XKp2vQ1",
    "ends_at": 1789392000,
    "object": "discount"
  },
  "dunning_attempts_notified": 0,
  "dunning_last_notified_at": 1789392000,
  "id": "sub_9XKp2vQ1",
  "last_payment_at": 1789392000,
  "last_payment_id": "pay_9XKp2vQ1",
  "livemode": false,
  "mrr_cents": 1900,
  "next_charge_amount_cents": 1900,
  "next_payment_attempt": 0,
  "object": "subscription",
  "payment_count": 0,
  "payment_method": {
    "brand": "string",
    "exp_month": 0,
    "exp_year": 0,
    "last4": "string",
    "type": "creditcard"
  },
  "price": {
    "active": false,
    "amount_cents": 1900,
    "billing_scheme": "per_unit",
    "currency": "EUR",
    "id": "obj_9XKp2vQ1",
    "interval": "month",
    "object": "price.summary",
    "payment_methods": [
      "creditcard"
    ],
    "payment_methods_from_account": false,
    "product_id": "prod_9XKp2vQ1",
    "product_name": "Ada Lovelace",
    "tax_behavior": "inclusive",
    "tiers_mode": "graduated",
    "trial_days": 0,
    "unit_amount_decimal": "string",
    "usage_type": "licensed"
  },
  "price_id": "price_9XKp2vQ1",
  "refund_eligibility": {
    "amount_cents": 1900,
    "currency": "EUR",
    "days_remaining": 0,
    "eligible": false,
    "object": "refund_eligibility",
    "reason": "no_charge",
    "window_ends_at": 1789392000
  },
  "renewal_state": "auto_renew",
  "serves_customer": false,
  "status": "incomplete",
  "trial_end": 0,
  "trial_start": 0
}