Subscription change requests

Multi-step workflow for previewing and applying subscription changes with explicit payment control, conflict resolution, and idempotent retry support.

View as Markdown

The change request API provides a multi-step workflow for making complex subscription changes with preview, payment control, and retry safety. Use this when you need to:

  • Preview changes before committing — show customers the exact proration amounts before applying
  • Require payment before changes — guarantee payment succeeds before modifying the subscription (Charge First pattern)
  • Handle retries safely — built-in idempotency prevents double-charging on retry
  • Batch multiple changes — combine item updates, coupon changes, and balance adjustments in one atomic operation

For simpler immediate changes within the same billing interval, consider the update subscription items endpoint instead.

How it works

The change request workflow has four steps:

  1. Create — Initialize a draft change request for a subscription
  2. Add changes — Specify item changes (add/update/delete), coupon changes, and balance adjustments
  3. Preview — Compute execution plan, detect conflicts, calculate proration (transitions to READY)
  4. Apply — Collect payment (if needed) then execute changes (transitions to APPLIED)

Each change request is an independent entity with its own ID and status, allowing you to create, preview, and apply changes over multiple API calls.

Create a change request

Create a new draft change request for a subscription. Only one active (DRAFT or READY) change request can exist per subscription.

$curl -X POST https://api.paymentkit.com/api/{account_id}/change-requests \
>-H "Authorization: Bearer sk_live_..." \
>-H "Content-Type: application/json" \
>-d '{
> "subscription_id": "sub_abc123",
> "reason": "Upgrade to annual plan",
> "expires_in_hours": 24
>}'

Request fields

FieldTypeRequiredDescription
subscription_idstringYesExternal ID of the subscription to modify
reasonstringNoOptional reason for the change (stored as metadata)
expires_in_hoursintegerNoHours until the draft expires (default: 24). The change request automatically transitions to EXPIRED status after this time if not applied.

Response

1{
2 "id": "chg_live_a1b2c3d4",
3 "subscription_id": "sub_abc123",
4 "status": "draft",
5 "reason": "Upgrade to annual plan",
6 "created_at": "2026-04-14T10:00:00Z",
7 "expires_at": "2026-04-15T10:00:00Z",
8 "item_changes": [],
9 "coupon_changes": [],
10 "balance_changes": []
11}

If the subscription already has an active change request, this endpoint returns a 409 Conflict error. Cancel the existing request first or use its ID to continue the workflow.

Add changes

Add item changes, coupon changes, or balance adjustments to a draft change request. You can call this endpoint multiple times to build up a complex change plan.

$curl -X POST https://api.paymentkit.com/api/{account_id}/change-requests/{change_request_id}/changes \
>-H "Authorization: Bearer sk_live_..." \
>-H "Content-Type: application/json" \
>-d '{
> "item_changes": [
> {
> "action": "update",
> "item_id": "si_monthly_plan",
> "price_id": "price_annual_plan"
> },
> {
> "action": "add",
> "price_id": "price_addon_support",
> "quantity": 1,
> "apply_at_end": false
> }
> ],
> "coupon_changes": [
> {
> "action": "add",
> "coupon_id": "coup_welcome20"
> }
> ]
>}'

Item change actions

ActionRequired fieldsOptional fieldsDescription
addprice_idquantity, apply_at_endAdd a new subscription item. If apply_at_end: true, the item is created inactive and activates at the next renewal. Quantity defaults to 1.
updateitem_id, price_id OR quantityprice_id, quantity, apply_at_endUpdate an existing item’s price and/or quantity. If apply_at_end: true, the update is deferred to the next renewal.
dropitem_idapply_at_endRemove an item. If apply_at_end: true, the item is marked for removal at the next renewal.

Immediate vs. scheduled changes:

  • apply_at_end: false (default) — Changes apply immediately with prorated billing
  • apply_at_end: true — Changes are scheduled for the end of the current billing period (no proration)

Scheduled changes use different execution actions internally (add_scheduled, update_scheduled, drop_scheduled) but are transparently handled by the API.

Coupon changes

ActionRequired fieldsDescription
addcoupon_idAttach a coupon to the subscription. Replaces any existing coupon.
removecoupon_idRemove the specified coupon from the subscription.

Balance changes

Balance changes (adding credits or debits to customer account balance) are defined in the API schema but not yet implemented. Attempting to add balance changes will result in a 501 Not Implemented error during the Apply step.

Response

1{
2 "change_request": {
3 "id": "chg_live_a1b2c3d4",
4 "subscription_id": "sub_abc123",
5 "status": "draft",
6 "item_changes": [
7 {
8 "action": "update",
9 "item_id": "si_monthly_plan",
10 "price_id": "price_annual_plan",
11 "quantity": null,
12 "apply_at_end": false
13 },
14 {
15 "action": "add",
16 "price_id": "price_addon_support",
17 "quantity": 1,
18 "apply_at_end": false
19 }
20 ],
21 "coupon_changes": [
22 {
23 "action": "add",
24 "coupon_id": "coup_welcome20"
25 }
26 ],
27 "balance_changes": []
28 },
29 "changes_count": 3
30}

You can call this endpoint multiple times to incrementally build your change plan. Each call appends to the existing changes.

Preview a change request

Preview the execution plan and proration amounts for a change request. This is a pure computation — no changes are made to the subscription, but the change request transitions from DRAFT to READY status and caches the preview result.

$curl -X POST https://api.paymentkit.com/api/{account_id}/change-requests/{change_request_id}/preview \
>-H "Authorization: Bearer sk_live_..."

Response

1{
2 "change_request": {
3 "id": "chg_live_a1b2c3d4",
4 "status": "ready",
5 "last_preview": {
6 "items_to_add": [
7 {"price_id": "price_addon_support", "quantity": 1}
8 ],
9 "items_to_update": [
10 {"item_id": "si_monthly_plan", "price_id": "price_annual_plan", "quantity": null}
11 ],
12 "items_to_delete": [],
13 "coupon_to_add": "coup_welcome20",
14 "coupon_to_remove": null,
15 "balance_to_apply_atom": 0,
16 "proration_credit_atom": -5000,
17 "proration_charge_atom": 100000,
18 "invoice_total_atom": 95000,
19 "execution_plan": {
20 "steps": [
21 {
22 "phase": 1,
23 "action": "update",
24 "item_external_id": "si_monthly_plan",
25 "price_external_id": "price_annual_plan",
26 "quantity": null
27 },
28 {
29 "phase": 1,
30 "action": "add",
31 "price_external_id": "price_addon_support",
32 "quantity": 1
33 },
34 {
35 "phase": 2,
36 "action": "COUPON_ADD",
37 "coupon_external_id": "coup_welcome20"
38 }
39 ],
40 "auto_resolutions": []
41 }
42 }
43 },
44 "preview": { /* same as last_preview */ },
45 "execution_plan": { /* same as last_preview.execution_plan */ }
46}

Preview fields

FieldDescription
items_to_addItems that will be added to the subscription
items_to_updateItems that will be updated (price and/or quantity changes)
items_to_deleteItems that will be removed
coupon_to_addCoupon ID being attached (null if none)
coupon_to_removeCoupon ID being removed (null if none)
balance_to_apply_atomAccount balance credit/debit amount in atomic units
proration_credit_atomTotal credits for unused time (negative value)
proration_charge_atomTotal charges for new items (positive value)
invoice_total_atomNet amount customer will be charged (max of 0 and net_atom)
execution_plan.stepsOrdered list of steps that will be executed during Apply
execution_plan.auto_resolutionsConflicts that were automatically resolved

Execution plan

The execution plan shows the exact sequence of operations that will be performed when you call Apply. Steps are executed in phase order:

  • Phase 1 — Item changes (ADD, UPDATE, DROP, and their _scheduled variants)
  • Phase 2 — Coupon changes (COUPON_ADD, COUPON_REMOVE)
  • Phase 3 — Balance changes (BALANCE_CREDIT_ADD, etc.) [not yet implemented]

Each step includes:

  • action — The operation type (e.g., add, update_scheduled, COUPON_ADD)
  • item_external_id — ID of the item being modified (for item changes)
  • price_external_id — New price ID (for ADD/UPDATE)
  • quantity — New quantity (for ADD/UPDATE, null means keep existing)
  • coupon_external_id — Coupon ID (for coupon operations)

Conflict detection: If the preview detects unresolvable conflicts (e.g., trying to update and delete the same item), it returns a 409 Conflict error with details about the conflicting changes. You must modify the change request to resolve these conflicts before calling Apply.

Apply a change request

Execute the change plan with the Charge First pattern: payment is collected BEFORE any subscription changes are made. If payment fails, the subscription remains unchanged and the change request stays in READY status for retry.

$curl -X POST https://api.paymentkit.com/api/{account_id}/change-requests/{change_request_id}/apply \
>-H "Authorization: Bearer sk_live_..." \
>-H "Content-Type: application/json" \
>-d '{
> "payment_method_id": "pm_card123"
>}'

Request fields

FieldTypeRequiredDescription
payment_method_idstringNoOptional payment method to use instead of the subscription’s default. Must belong to the customer.

Charge First pattern

The Apply operation follows a strict three-phase pattern to ensure payment succeeds before making any changes:

Phase 1: Validate & Prepare

  • Verify change request is in READY status
  • Load subscription, items, and prices
  • Build execution plan and verify no conflicts
  • Calculate invoice total from cached preview

Phase 2: Payment (idempotent)

If invoice total > 0:

  1. Mark payment as being attempted (checkpoint)
  2. Create proration invoice with line items
  3. Finalize invoice to OPEN status
  4. Create payment intent
  5. Attempt payment via MIT orchestrator
  6. If payment fails: Mark payment_attempted=true, raise error, stay in READY
  7. If payment succeeds: Mark payment_recorded=true (checkpoint), proceed to Phase 3

If invoice total ≤ 0:

  • Zero invoice: Mark as no_payment_required, proceed to Phase 3
  • Negative invoice (downgrade): Issue credit note, proceed to Phase 3

Idempotency: On retry, if payment_recorded=true, skip payment collection entirely and proceed directly to Phase 3

Phase 3: Execute Changes

  • Execute all steps from execution plan in phase order
  • Handle item ADD/UPDATE/DROP operations (immediate and scheduled)
  • Apply coupon changes
  • Cancel original subscription if all items removed
  • Transition change request to APPLIED status

Why Charge First?

  • Prevents orphaned state — customer never has new items without payment
  • Atomic success — either payment succeeds AND changes apply, or nothing happens
  • Retry safety — if network fails after payment but before database commit, retry won’t double-charge

Response (success)

1{
2 "change_request": {
3 "id": "chg_live_a1b2c3d4",
4 "status": "applied",
5 "applied_at": "2026-04-14T10:30:00Z"
6 },
7 "result": {
8 "subscription_external_id": "sub_abc123",
9 "new_subscriptions": [],
10 "invoice_external_id": "inv_proration456",
11 "payment_status": "paid",
12 "step_results": [
13 {
14 "phase": 1,
15 "action": "update",
16 "item_external_id": "si_monthly_plan",
17 "result": "success"
18 },
19 {
20 "phase": 1,
21 "action": "add",
22 "item_external_id": null,
23 "result": "success"
24 },
25 {
26 "phase": 2,
27 "action": "COUPON_ADD",
28 "result": "success"
29 }
30 ]
31 }
32}

Response (payment failed)

When payment fails, the API returns a 402 Payment Required error:

1{
2 "error": "payment_failed",
3 "message": "Payment failed for change plan",
4 "payment_status": "failed",
5 "payment_error": "Your card was declined. Please try a different payment method.",
6 "orchestrator_summary": "Card declined by issuer (insufficient_funds)"
7}

The change request remains in READY status. You can retry the Apply operation:

  • With a different payment method
  • After the customer updates their payment method
  • Same request ID works — idempotency prevents double-charging

Payment status values

StatusDescription
paidPayment succeeded
processingAsync payment (ACH/SEPA) submitted, awaiting confirmation
failedPayment failed
requires_actionCustomer action required (e.g., 3D Secure)
no_payment_methodNo payment method available
already_paidPayment was already recorded (idempotent retry)
no_payment_requiredZero or negative invoice (no payment needed)

3D Secure and requires_action: If payment requires customer action (3D Secure authentication), the API returns payment_status: "requires_action" with a payment_intent_id. You must redirect the customer to complete authentication, then retry the Apply call after authentication succeeds. The idempotency mechanism ensures the customer isn’t double-charged.

Cancel a change request

Cancel a draft or ready change request. Once applied, a change request cannot be cancelled.

$curl -X DELETE https://api.paymentkit.com/api/{account_id}/change-requests/{change_request_id} \
>-H "Authorization: Bearer sk_live_..."

Response

1{
2 "id": "chg_live_a1b2c3d4",
3 "status": "cancelled",
4 "cancelled_at": "2026-04-14T11:00:00Z"
5}

Cancelled change requests are retained for audit purposes. The subscription can now have a new change request created.

Status transitions

Change requests follow a strict state machine:

DRAFT → READY → APPLIED
↓ ↓
CANCELLED CANCELLED
EXPIRED (after expires_at)
StatusDescriptionAllowed operations
draftInitial state after creationAdd changes, Preview, Cancel
readyPreview completed, ready to applyApply, Cancel
appliedSuccessfully executedNone (terminal)
cancelledManually cancelledNone (terminal)
expiredDraft expired before previewNone (terminal)

Adding changes to READY: If you call Add Changes on a READY change request, it transitions back to DRAFT. You must call Preview again before Apply.

Proration calculation

Proration is calculated for immediate changes only (not scheduled changes). The calculation depends on the change type:

DROP (remove item)

Credit for unused time at current price

credit = (days_remaining / days_in_period) × item_price × quantity

Example: Remove a $100/month item with 15 days remaining in a 30-day period

  • Credit: (15/30) × 100=100 = 50

UPDATE (change price or quantity)

Credit for old price + Charge for new price

credit = (days_remaining / days_in_period) × old_price × old_quantity
charge = (days_remaining / days_in_period) × new_price × new_quantity
net = charge - credit

Example: Upgrade from 100/monthto100/month to 200/month with 15 days remaining

  • Credit: (15/30) × 100=100 = 50
  • Charge: (15/30) × 200=200 = 100
  • Net: 100100 - 50 = $50

ADD (new item)

Charge for remaining time at new price

charge = (days_remaining / days_in_period) × price × quantity

Example: Add a $50/month add-on with 15 days remaining

  • Charge: (15/30) × 50=50 = 25

Scheduled changes (apply_at_end: true)

No proration — scheduled changes don’t affect the current period billing. They execute at the next renewal with no mid-cycle adjustments.

Downgrade handling

When the net proration is negative (more credits than charges), PaymentKit handles it automatically:

  • Negative net < 0: Issue a credit note to customer balance immediately
  • Zero net = 0: No invoice created, changes apply without payment
  • Positive net > 0: Create invoice and collect payment

Example: Downgrade from 200/monthto200/month to 100/month with 15 days remaining

  • Credit: (15/30) × 200=200 = 100
  • Charge: (15/30) × 100=100 = 50
  • Net: 5050 - 100 = -50Issue50 → Issue 50 credit note

Response fields for downgrades:

1{
2 "change_request": {...},
3 "result": {
4 "subscription_id": "sub_abc123",
5 "invoice_id": null,
6 "credit_note_id": "cn_abc123",
7 "payment_status": "not_required",
8 "new_subscriptions": [],
9 "step_results": [...]
10 }
11}

Common workflows

Preview before showing to customer

Build a change request, preview it, show the preview to the customer, then apply only after they confirm:

1# 1. Create draft
2change_request = client.change_requests.create(
3 account_id="acc_abc123",
4 subscription_id="sub_abc123"
5)
6
7# 2. Add changes
8client.change_requests.add_changes(
9 account_id="acc_abc123",
10 change_request_id=change_request["id"],
11 item_changes=[{
12 "action": "update",
13 "item_id": "si_monthly_plan",
14 "price_id": "price_annual_plan"
15 }]
16)
17
18# 3. Preview
19preview = client.change_requests.preview(
20 account_id="acc_abc123",
21 change_request_id=change_request["id"]
22)
23
24# 4. Show preview to customer
25print(f"Proration credit: ${preview['preview']['proration_credit_atom'] / 100}")
26print(f"Proration charge: ${preview['preview']['proration_charge_atom'] / 100}")
27print(f"Total to charge: ${preview['preview']['invoice_total_atom'] / 100}")
28
29# 5. Customer confirms, apply
30result = client.change_requests.apply(
31 account_id="acc_abc123",
32 change_request_id=change_request["id"]
33)
34
35if result["result"]["payment_status"] == "paid":
36 print("Success! Subscription updated.")
37else:
38 print(f"Payment failed: {result['result'].get('payment_error')}")

Handle payment failure with retry

1try:
2 result = client.change_requests.apply(
3 account_id="acc_abc123",
4 change_request_id="chg_live_a1b2c3d4"
5 )
6except PaymentFailedError as e:
7 # Change request stays in READY status
8 # Prompt customer to update payment method
9 print(f"Payment failed: {e.payment_error}")
10
11 # After customer updates payment method, retry with new PM
12 result = client.change_requests.apply(
13 account_id="acc_abc123",
14 change_request_id="chg_live_a1b2c3d4",
15 payment_method_id="pm_new_card"
16 )
17 # Idempotency ensures no double-charging

Batch multiple changes atomically

1# Create draft
2change_request = client.change_requests.create(
3 account_id="acc_abc123",
4 subscription_id="sub_abc123"
5)
6
7# Add all changes in one call
8client.change_requests.add_changes(
9 account_id="acc_abc123",
10 change_request_id=change_request["id"],
11 item_changes=[
12 {"action": "update", "item_id": "si_basic", "price_id": "price_pro"},
13 {"action": "add", "price_id": "price_addon_storage", "quantity": 2},
14 {"action": "drop", "item_id": "si_old_addon"}
15 ],
16 coupon_changes=[
17 {"action": "add", "coupon_id": "coup_enterprise20"}
18 ]
19)
20
21# Preview and apply
22preview = client.change_requests.preview(
23 account_id="acc_abc123",
24 change_request_id=change_request["id"]
25)
26
27result = client.change_requests.apply(
28 account_id="acc_abc123",
29 change_request_id=change_request["id"]
30)

All changes are applied atomically — either all succeed or none are applied.

Schedule changes for end of period

1# Create and add changes
2change_request = client.change_requests.create(
3 account_id="acc_abc123",
4 subscription_id="sub_abc123"
5)
6
7client.change_requests.add_changes(
8 account_id="acc_abc123",
9 change_request_id=change_request["id"],
10 item_changes=[
11 {
12 "action": "update",
13 "item_id": "si_monthly_plan",
14 "price_id": "price_annual_plan",
15 "apply_at_end": True # Schedule for period end
16 }
17 ]
18)
19
20# Preview (no proration for scheduled changes)
21preview = client.change_requests.preview(
22 account_id="acc_abc123",
23 change_request_id=change_request["id"]
24)
25# proration_credit_atom and proration_charge_atom will be 0
26
27# Apply (creates pending change on subscription)
28result = client.change_requests.apply(
29 account_id="acc_abc123",
30 change_request_id=change_request["id"]
31)
32# payment_status will be "no_payment_required"

The change executes automatically at the subscription’s current_period_end.

Comparison with other endpoints

FeatureChange RequestUpdate ItemsChange Plan (deprecated)
Preview before apply✅ Explicit preview step❌ No preview❌ No preview
Payment before changes✅ Guaranteed (Charge First)✅ Guaranteed (Charge First with always_invoice)✅ Guaranteed (Charge First, always)
Idempotent retry✅ Built-in❌ No❌ No
Status tracking✅ DRAFT → READY → APPLIED❌ No❌ No
Multi-step workflow✅ Create → Preview → Apply❌ Single call❌ Single call
Scheduled changesapply_at_end: truestart_at_end/drop_at_endeffective_at: "period_end"
Same-interval changes✅ Supported✅ Primary use case⚠️ Supported but not recommended
Cross-interval changes✅ Supported❌ Not supported✅ Primary use case
Conflict detection✅ Automatic in Preview❌ No❌ No
Deprecation status✅ Current✅ Current⚠️ Planned for deprecation

When to use:

  • Change Request (recommended) — When you need explicit preview before applying changes, multi-step workflow with user confirmation, or idempotent retry safety
  • Update Items — For simple same-interval changes when preview and multi-step workflow aren’t needed
  • Change Plan (deprecated) — Legacy endpoint for cross-interval changes. Use Change Request instead for new integrations.

Note: All three endpoints use Charge First pattern to guarantee payment succeeds before modifying subscriptions. The key differences are preview capability, workflow complexity, and retry safety.

Webhooks

Immediate changes

When a change request is applied with immediate changes:

  1. subscription.change_request.created — Change request created (DRAFT)
  2. subscription.change_request.previewed — Preview completed (READY)
  3. invoice.created — Proration invoice created (if invoice_total > 0)
  4. invoice.paid — Payment succeeded (if invoice_total > 0)
  5. customer.subscription.updated — Subscription items modified
  6. subscription.change_request.applied — Change request applied (APPLIED)

Scheduled changes

When a change request includes scheduled changes (apply_at_end: true):

  1. Change request webhooks fire as normal (created, previewed, applied)
  2. customer.subscription.updated — Pending changes stored on subscription
  3. At period end, when the scheduled change executes:
    • customer.subscription.updated — Items activated/deactivated/updated

Failed payment

When payment fails during Apply:

  1. subscription.change_request.payment_failed — Payment failed, still in READY
  2. Customer updates payment method
  3. Retry Apply → same sequence as successful payment

Delivery order is NOT guaranteed. Design webhook handlers to be idempotent and don’t assume ordering.