Subscription Management
The Management group lets an authenticated customer mutate their own subscription contracts: pause/resume/cancel, swap products, change frequency, edit shipping and payment, manage lines, and act on billing cycles.
All paths are relative to the base URL:
https://<your-app-domain>/storefront-api/v1Every management endpoint requires both auth layers on every request:
X-Joy-App-Id: <app-id>
X-Joy-Secret-Key: sk_live_xxx # server-side only — never expose in the browser
X-Session-Id: <session-id>The shopId is always derived from the session, never from a header. {contractId} is the numeric subscriptionContractId. Accessing a contract that is not owned by the authenticated customer returns 404 NOT_FOUND (this also covers contracts that do not exist, to prevent IDOR).
Write methods (POST / PUT / DELETE) are subject to the write rate-limit dimension (20 requests / 60s per customer, burst 5). Exceeding any limit returns 429 RATE_LIMITED. Many of these endpoints return { "success": true } with no data on success.
Endpoint summary
| Method | Path | Description |
|---|---|---|
| PUT | /subscriptions/{contractId}/status | Pause / resume / cancel. |
| POST | /subscriptions/{contractId}/cancel | Cancel with a reason code. |
| PUT | /subscriptions/{contractId}/product | Swap product / variant. |
| PUT | /subscriptions/{contractId}/frequency | Change delivery frequency. |
| GET | /subscriptions/{contractId}/shipping-address | Get current shipping address. |
| PUT | /subscriptions/{contractId}/shipping-address | Update shipping address. |
| GET | /subscriptions/{contractId}/payment-method | Get masked payment metadata. |
| PUT | /subscriptions/{contractId}/payment-method | Update payment method. |
| PUT | /subscriptions/{contractId}/shipping-option | Change shipping rate. |
| PUT | /subscriptions/{contractId}/subscription-discount | Apply a subscription discount. |
| POST | /subscriptions/{contractId}/lines | Add a recurring line. |
| DELETE | /subscriptions/{contractId}/lines/{lineId} | Remove a recurring line. |
| PUT | /subscriptions/{contractId}/billing-cycle | Skip / reschedule / resume / order-now a cycle. |
| PUT | /subscriptions/{contractId}/upcoming-orders/{cycleIndex}/lines/{lineId} | Update an upcoming-order line. |
| DELETE | /subscriptions/{contractId}/upcoming-orders/{cycleIndex}/lines/{lineId} | Remove an upcoming-order line. |
| POST | /subscriptions/{contractId}/upcoming-orders/products | Add a one-off product to an upcoming order. |
| POST | /subscriptions/{contractId}/discount | Apply a discount code. |
| DELETE | /subscriptions/{contractId}/discount | Remove an applied discount. |
Status — pause / resume / cancel
PUT /subscriptions/{contractId}/status
Body
| Field | Type | Required | Values |
|---|---|---|---|
status | string | yes | pause | resume | cancel |
{ "status": "pause" }Response
{ "success": true }curl -X PUT "https://<your-app-domain>/storefront-api/v1/subscriptions/123456789/status" \
-H "X-Joy-App-Id: <app-id>" \
-H "X-Joy-Secret-Key: sk_live_xxx" \
-H "X-Session-Id: <session-id>" \
-H "Content-Type: application/json" \
-d '{ "status": "pause" }'Cancel with a reason
POST /subscriptions/{contractId}/cancel
Cancel with a structured reason. reasonCode is threaded into the outbound subscription_contracts/cancel webhook payload.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
reasonCode | string | yes | too_expensive | no_longer_needed | switching | quality_issue | other |
comment | string | no | Max 500 chars. |
retentionOfferAccepted | boolean | no |
{ "reasonCode": "too_expensive", "comment": "Found a cheaper option" }Response
{ "success": true, "data": { "contractId": 123456789, "status": "cancelled" } }Swap product / variant
PUT /subscriptions/{contractId}/product
Extra planData fields are forwarded verbatim to the swap service.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
newVariantId | integer | yes | Target variant. |
lineId | string | no | Line to swap. |
currentVariantId | integer | no | |
newProductId | integer | no | |
sellingPlanId | string | no | |
planData | object | no | Plan config; discountConfig.enabled is normalized server-side. |
{ "newVariantId": 4455667788, "lineId": "gid://shopify/SubscriptionLine/1" }Response
{ "success": true }Change frequency
PUT /subscriptions/{contractId}/frequency
Body
| Field | Type | Required | Notes |
|---|---|---|---|
input.interval | string | yes | DAY | WEEK | MONTH | YEAR |
input.intervalCount | integer | yes | Positive. |
previousPlan | object | no | |
initPlan | object | no | |
baseCurrency | string | no | |
returnedCurrency | string | no |
{ "input": { "interval": "MONTH", "intervalCount": 2 } }Response
{ "success": true, "data": { } }Shipping address
Get current address
GET /subscriptions/{contractId}/shipping-address
Returns the current delivery address (PII projection).
{
"success": true,
"data": {
"firstName": "…", "lastName": "…",
"address1": "…", "address2": null,
"city": "…", "province": "…", "provinceCode": "…",
"country": "…", "countryCode": "…",
"zip": "…", "phone": "…", "company": null
}
}Update address
PUT /subscriptions/{contractId}/shipping-address
Body
| Field | Type | Required |
|---|---|---|
input.address1 | string | yes |
input.city | string | yes |
input.zip | string | yes |
input.firstName, input.lastName, input.address2, input.province, input.provinceCode, input.country, input.countryCode, input.phone, input.company | string | no |
{ "input": { "address1": "123 Main St", "city": "Hanoi", "zip": "100000" } }Response
{ "success": true }Payment method
Get masked metadata
GET /subscriptions/{contractId}/payment-method
Masked payment metadata only — never PAN/CVV.
{
"success": true,
"data": { "id": "…", "brand": "visa", "last4": "4242", "expiryMonth": 12, "expiryYear": 2027 }
}Update payment method
PUT /subscriptions/{contractId}/payment-method
Assign a tokenized payment method.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
input.paymentMethodId | string | yes | Tokenized payment method id. |
customerPaymentMethod | object | no | { brand, last4, expiryMonth, expiryYear } for display mirroring. |
{ "input": { "paymentMethodId": "<payment-method-id>" } }Response
{ "success": true }Change shipping option
PUT /subscriptions/{contractId}/shipping-option
Body
| Field | Type | Required | Notes |
|---|---|---|---|
shippingOption.handle | string | yes | |
shippingOption.title, shippingOption.price, shippingOption.code | mixed | no |
{ "shippingOption": { "handle": "standard", "title": "Standard", "price": "5.00" } }Response
{ "success": true }Subscription discount
PUT /subscriptions/{contractId}/subscription-discount
Apply a subscription-level discount.
Merchant-gated: returns
403 FORBIDDENunless the merchant enabledcustomerPortal.allowSubscriptionDiscount(disabled by default).
Body
| Field | Type | Required | Values |
|---|---|---|---|
discountType | string | yes | PERCENTAGE | FIXED_AMOUNT |
discountValue | number | yes | Positive. |
applyToOrders | mixed | no | Scope hint. |
{ "discountType": "PERCENTAGE", "discountValue": 10 }Response
{ "success": true }Recurring lines
Add a line
POST /subscriptions/{contractId}/lines
Body
| Field | Type | Required | Notes |
|---|---|---|---|
lines | array | yes | At least 1 line { variantId, quantity, productId?, sellingPlanId? }. |
input | object | no | |
currentLines | array | no | Used for email diffing. |
giftMeta | mixed | no |
{ "lines": [ { "variantId": 4455667788, "quantity": 1 } ] }Response
{ "success": true, "data": { } }Remove a line
DELETE /subscriptions/{contractId}/lines/{lineId}
lineId is a path param; the body carries optional metadata.
Body (optional)
| Field | Type | Notes |
|---|---|---|
sellingPlanId | string | |
input | object | |
currentLines | array |
Response
{ "success": true, "data": { } }Billing cycle
PUT /subscriptions/{contractId}/billing-cycle
Skip / reschedule / resume / order-now a billing cycle.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes | skip | reschedule | resume | order_now |
cycleIndex | integer | no | Target cycle (at least 0). |
newBillingDate | string | conditionally | Required when type is reschedule. |
An unsupported
typereturns400 INVALID_PARAM.
{ "type": "skip", "cycleIndex": 5 }{ "type": "reschedule", "cycleIndex": 5, "newBillingDate": "2026-08-01T00:00:00.000Z" }Response
{ "success": true, "data": { } }Upcoming-order lines
Update an upcoming-order line
PUT /subscriptions/{contractId}/upcoming-orders/{cycleIndex}/lines/{lineId}
Edit a line on a specific upcoming order/cycle.
Path: contractId, cycleIndex (integer), lineId.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
input.quantity | integer | no | Positive. |
input.sellingPlanId | string | no | |
input.variantId | integer | no |
{ "input": { "quantity": 2 } }Response
{ "success": true, "data": { } }Remove an upcoming-order line
DELETE /subscriptions/{contractId}/upcoming-orders/{cycleIndex}/lines/{lineId}
Remove a line from a specific upcoming order/cycle. No body.
Response
{ "success": true }Add a one-off product to an upcoming order
POST /subscriptions/{contractId}/upcoming-orders/products
Body
| Field | Type | Required | Notes |
|---|---|---|---|
cycleIndex | integer | yes | Target cycle (at least 0). |
lines | array | yes | At least 1 line { variantId, quantity, productId?, sellingPlanId? }. |
{ "cycleIndex": 5, "lines": [ { "variantId": 4455667788, "quantity": 1 } ] }Response
{ "success": true, "data": { } }Discount code
Apply a discount code
POST /subscriptions/{contractId}/discount
Body
| Field | Type | Required | Notes |
|---|---|---|---|
discountCode | string | yes | The code to redeem. |
applyToOrders | mixed | no | Scope hint (all | next). |
isManual | boolean | no | Manual vs automatic apply. |
{ "discountCode": "SAVE10" }Response
{ "success": true, "data": { } }Remove an applied discount
DELETE /subscriptions/{contractId}/discount
Body
| Field | Type | Required | Notes |
|---|---|---|---|
discountId | string | no | Which applied discount to remove. |
isManual | boolean | no |
Response
{ "success": true }Errors
| HTTP | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Body fails validation. |
| 400 | INVALID_PARAM | Bad path param, or unsupported billing-cycle type. |
| 401 | AUTH_REQUIRED / SESSION_EXPIRED / INVALID_APP_CREDENTIALS | Missing/invalid session or app credentials. |
| 403 | SHOP_MISMATCH | App shop and session shop differ. |
| 403 | FORBIDDEN | Action disabled by the merchant (e.g. subscription-discount). |
| 404 | NOT_FOUND | Contract does not exist or is not owned by the customer (IDOR-safe). |
| 429 | RATE_LIMITED | Rate limit exceeded; see Retry-After, retryAfterSeconds, scope. |
| 500 | INTERNAL_ERROR | Generic server error. |
Error envelope:
{ "success": false, "error": { "code": "NOT_FOUND", "message": "..." } }