Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

Change subscription item

PATCH
/v1/subscriptions/{id}/items/{itemID}
curl --request PATCH \
--url http://localhost:8080/v1/subscriptions/example/items/example \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "quantity": 1, "new_plan_id": "example", "immediate": false }'

Changes the quantity or the plan of one item. Send exactly one of quantity or new_plan_id. The subscription must be active.

  • Quantity. Takes effect now.
  • Plan, immediate: true. Takes effect now. If the new plan has a different billing interval, the current period ends now and a new period starts on the new plan.
  • Plan, immediate: false (default). Scheduled for the end of the current period. The item shows it in pending_plan_id and pending_plan_effective_at. Cancel it with DELETE /v1/subscriptions/{id}/items/{itemID}/pending-change.

For a change that takes effect now on a plan billed in advance, Hardcount settles the rest of the period at once. An increase is invoiced. A decrease is credited to the customer’s balance with a credit note, or reduces the amount due when the period’s invoice is still unpaid. proration in the response describes the result. A plan billed in arrears is settled at the end of the period.

Changes to one subscription’s items run one at a time, and each is settled from the item as the change before it left it. A request that repeats a change that already finished finds nothing to change and gets 422.

Sends subscription.item.updated, or subscription.pending_change.scheduled for a scheduled plan change.

id
required
string
itemID
required
string
Idempotency-Key
string

Makes the call safe to retry. Send a unique value (a UUID works) and the same value on every retry. For 24 hours, a repeat with the same key, method, path and body gets the first response again, with an Idempotent-Replayed: true header, instead of running again. The error cases are in docs/api-surface.md. The usage routes do not take it: each usage event carries its own idempotency_key.

Media typeapplication/json
object
quantity

The new quantity. Must differ from the current one.

integer format: int64
>= 1
new_plan_id

The plan to move the item to. Must differ from the current one and use the same base-fee billing timing (in advance or in arrears).

string
immediate

Apply a plan change now instead of at the end of the period. Ignored for quantity changes.

boolean

The changed item and when the change takes effect.

Media typeapplication/json
object
item
required

One priced line of a subscription. It pairs a plan with a quantity.

object
id
required
string
tenant_id
string
subscription_id
required
string
plan_id
required
string
quantity
required
integer format: int64
metadata

The item’s stored metadata. Absent when empty.

object
key
additional properties
any
pending_plan_id

Plan the item moves to at pending_plan_effective_at. Absent when no change is scheduled.

string
pending_plan_effective_at
string format: date-time
plan_changed_at

When the plan was last changed immediately. Absent until the first such change.

string format: date-time
created_at
required
string format: date-time
updated_at
required
string format: date-time
effective_at
required

When the change takes effect. For a scheduled plan change, the end of the current period.

string format: date-time
proration

What Hardcount did to settle the rest of the period for an item change.

object
old_plan_id
required
string
new_plan_id
required
string
proration_factor
required

Share of the full billing cycle that was left, from 0 to 1.

number format: double
amount_cents
required

Gross amount, tax included: the invoice total (invoice), or the whole credit for unused time (credit and adjustment). credited_to_balance_cents, reduced_open_invoices_cents and held_on_draft_invoices_cents say where the credit went.

integer format: int64
type
required

invoice: a proration invoice was created. credit: some of the credit went to the customer’s balance, or no invoice had room for any of it (amount_cents is then 0). adjustment: none of it went to the balance.

string
Allowed values: invoice credit adjustment
invoice_id

The proration invoice. Present when type is invoice.

string
credited_to_balance_cents
required

The part of the credit added to the customer’s balance, tax included. On a paid invoice, all of its part. On an open invoice paid partly with credits, the part above what it still asks for: those credits go back to the balance. A part on an invoice whose payment is still processing counts here; if that payment fails, it reduces the invoice instead. 0 when type is invoice.

integer format: int64
reduced_open_invoices_cents
required

The part of the credit taken off the amount due on open invoices, tax included. 0 when type is invoice.

integer format: int64
held_on_draft_invoices_cents
required

The part of the credit on draft invoices, tax included. It is held until the draft is finalized, then applied by how the invoice stands: if paid, to the customer’s balance; if still open, off what it asks for first, as above. 0 when type is invoice.

integer format: int64
new_period_invoice

The invoice an immediate change to a plan with another billing interval created for the new period it starts.

object
id
required
string
invoice_number
required
string
status
required

draft when the invoice was not issued yet: collection is paused, or its tax could not be calculated yet. Its total may then change before it is finalized.

string
Allowed values: draft finalized
total_amount_cents
required

The invoice total when the change created it. On a draft whose tax could not be calculated yet, it has no tax in it.

integer format: int64
currency
required
string

Example

{
"proration": {
"type": "invoice"
},
"new_period_invoice": {
"status": "draft"
}
}

The body is not valid JSON.

Media typeapplication/json
object
error
object
type

One of invalid_request_error, authentication_error, rate_limit_error or api_error. Every error the API answers has this shape, including an unknown path (404, code route_not_found) and a method the path does not take (405, code method_not_allowed, with an Allow header). The few exceptions are listed in docs/api-surface.md.

string
code
string
message
string
request_id
string
param

The request field the error is about. Sent only when the error names one. A request body may hold only the fields its operation lists, spelled and cased as listed: any other field, or one the server sets (such as id), is refused with 422 and param naming it. An operation with no request body lists no fields: send no body, {} or null. The Stripe and Postmark webhooks and the LiteLLM callback take their sender’s body as it comes.

string

Example

{
"error": {
"type": "invalid_request_error"
}
}

Subscription not found, or the item is not on this subscription.

Media typeapplication/json
object
error
object
type

One of invalid_request_error, authentication_error, rate_limit_error or api_error. Every error the API answers has this shape, including an unknown path (404, code route_not_found) and a method the path does not take (405, code method_not_allowed, with an Allow header). The few exceptions are listed in docs/api-surface.md.

string
code
string
message
string
request_id
string
param

The request field the error is about. Sent only when the error names one. A request body may hold only the fields its operation lists, spelled and cased as listed: any other field, or one the server sets (such as id), is refused with 422 and param naming it. An operation with no request body lists no fields: send no body, {} or null. The Stripe and Postmark webhooks and the LiteLLM callback take their sender’s body as it comes.

string

Example

{
"error": {
"type": "invalid_request_error"
}
}

Code invalid_state: the subscription is not active, the new plan is archived, its meters (the meters it has a price for) overlap another live subscription of the customer, or its currency differs. Code unpaid_invoice_blocks_change: an increase would charge against an unpaid invoice for the current period (a written-off invoice is unpaid); settle or void it first. Code subscription_period_moved: the billing period changed while the request ran; read the subscription again and retry. Code item_changed: a plan change found the subscription’s items changed by another request that finished first; read the subscription again and retry. Code unbilled_period: a billing period ended and could not be invoiced; retry billing first.

Media typeapplication/json
object
error
object
type

One of invalid_request_error, authentication_error, rate_limit_error or api_error. Every error the API answers has this shape, including an unknown path (404, code route_not_found) and a method the path does not take (405, code method_not_allowed, with an Allow header). The few exceptions are listed in docs/api-surface.md.

string
code
string
message
string
request_id
string
param

The request field the error is about. Sent only when the error names one. A request body may hold only the fields its operation lists, spelled and cased as listed: any other field, or one the server sets (such as id), is refused with 422 and param naming it. An operation with no request body lists no fields: send no body, {} or null. The Stripe and Postmark webhooks and the LiteLLM callback take their sender’s body as it comes.

string

Example

{
"error": {
"type": "invalid_request_error"
}
}

Code validation_error: neither or both of quantity and new_plan_id are set; the value equals the current one; quantity is less than 1; the new plan is not found; or the new plan’s billing timing or interval does not fit the subscription. Also: the body has an unknown field (param names it; the body must use only the fields listed here).

Media typeapplication/json
object
error
object
type

One of invalid_request_error, authentication_error, rate_limit_error or api_error. Every error the API answers has this shape, including an unknown path (404, code route_not_found) and a method the path does not take (405, code method_not_allowed, with an Allow header). The few exceptions are listed in docs/api-surface.md.

string
code
string
message
string
request_id
string
param

The request field the error is about. Sent only when the error names one. A request body may hold only the fields its operation lists, spelled and cased as listed: any other field, or one the server sets (such as id), is refused with 422 and param naming it. An operation with no request body lists no fields: send no body, {} or null. The Stripe and Postmark webhooks and the LiteLLM callback take their sender’s body as it comes.

string

Example

{
"error": {
"type": "invalid_request_error"
}
}