Guides

Handle subscriptions

Inspect provider limits, understand preview/confirm, and cancel a subscription safely.

Continue Natya's Founder memo circle purchase. Use the integer SellApp subscription ID from the invoice (55 in the illustrative responses), not the provider's sub_... ID. These commands change real recurring billing; run only against a subscription you are authorized to change.

1. Read capabilities

Use Bash, cURL, jq, and a token with the invoice ability and corresponding store permission. Replace the key, slug, and invoice ID with your own values. The subscription may be linked to the purchase a little later: if subscription_id is still null, wait for the subscription event and retrieve again.

Prepare the session and retrieve the subscription ID
set -euo pipefail
export SELLAPP_API_BASE_URL='https://sell.app/api'
export SELLAPP_API_KEY='replace-me'
export SELLAPP_STORE_SLUG='launch-lab'
export SELLAPP_INVOICE_ID='9001'

api() {
  local method="$1" path="$2"
  shift 2
  curl --silent --show-error --fail-with-body \
    --request "$method" --url "${SELLAPP_API_BASE_URL}${path}" \
    --header "Authorization: Bearer ${SELLAPP_API_KEY}" \
    --header "X-STORE: ${SELLAPP_STORE_SLUG}" \
    --header 'Accept: application/json' \
    --dump-header /dev/stderr "$@"
}

SELLAPP_SUBSCRIPTION_ID=$(api GET "/v2/invoices/${SELLAPP_INVOICE_ID}" \
  | jq -er '.data.subscription_id')
capabilities_json=$(api GET "/v2/subscriptions/${SELLAPP_SUBSCRIPTION_ID}/capabilities")
jq '.data.capabilities' <<< "$capabilities_json"
unset SELLAPP_PREVIEW_TOKEN

Capabilities tell you which actions this subscription supports and who can perform them. A 200 response can include:

Capabilities — abbreviated, provider-dependent
{"data":{"id":55,"subscription_id":"sub_founder_memo_55","capabilities":{"shift_billing_date":{"status":"unsupported","seller_reason":"Stripe does not support arbitrary next-renewal-date changes for existing subscriptions through this provider flow."},"cancel_at_period_end":{"status":"available"},"change_plan":{"status":"customer_only"}}}}

If a capability is unsupported, customer_only, or otherwise not usable by the seller, stop and read its reason. Provider capabilities and variant policy both apply; an available capability does not override date or state restrictions.

The seller API's plan-change preview/confirm operations reject customer-owned plan changes with 422. Both current Stripe and PayPal provider flows also report renewal-date changes as unsupported. Route availability does not mean a provider implements the action. Do not put a seller key in a customer browser to work around these boundaries.

For Natya's Stripe subscription, skip the renewal-date branches below and use the supported cancellation example. The guarded preview/confirm code documents the complete exchange for a provider that reports support; it is not a successful Stripe or PayPal recipe.

2. Preview a renewal-date change

This block checks capability and skips the unsupported request for the current Stripe/PayPal flows. If a future provider reports support, choose a future UTC date permitted by the subscription's renewal-date policy. The example date is illustrative; replace it before running if it is past or outside the permitted shift window.

Preview without confirming the billing change
if jq -e '.data.capabilities.shift_billing_date.status | . == "available" or . == "seller_only"' \
  <<< "$capabilities_json" >/dev/null; then
  export SELLAPP_RENEWAL_DATE='2026-10-01T12:00:00Z'
  renewal_body=$(jq -nc --arg date "$SELLAPP_RENEWAL_DATE" '{
    renewal_date:$date,
    reason:"Align Natya with the next reading-room cycle."
  }')
  preview_json=$(api POST "/v2/subscriptions/${SELLAPP_SUBSCRIPTION_ID}/actions/change-renewal-date/preview" \
    --header 'Content-Type: application/json' \
    --data "$renewal_body")
  SELLAPP_PREVIEW_TOKEN=$(jq -er '.data.preview_token' <<< "$preview_json")
  jq '.data | {expires_at, preview_payload, request_payload, customer_message, seller_message}' \
    <<< "$preview_json"
else
  printf '%s\n' 'Renewal-date changes are unsupported; no preview request was sent.'
fi

When supported, a 200 response supplies data.preview_token, expires_at, preview_payload, and request_payload. Preview saves a token without confirming the change. Review its actual billing effects and expiry.

Ignoring the current Stripe restriction can produce this 422 response (selected fields; other policy or state failures can differ):

Unsupported Stripe renewal-date change — abbreviated
{"type":"validation_error","code":"validation_failed","errors":{"subscription":["Stripe does not support arbitrary next-renewal-date changes for existing subscriptions through this provider flow."]}}

Do not retry that unchanged request. There is no success token to confirm.

3. Confirm the exact previewed input

Run this block only after reviewing and accepting the preview. Preserve the original renewal date and reason, and use the token returned for this subscription and the account that requested it. A token cannot be reused for a different date or after expiry.

Confirm with a stable key for this one logical change
if [[ -n "${SELLAPP_PREVIEW_TOKEN:-}" ]]; then
  export SELLAPP_IDEMPOTENCY_KEY="founder-memo-${SELLAPP_SUBSCRIPTION_ID}-renewal-${SELLAPP_PREVIEW_TOKEN}"
  confirm_body=$(jq -c --arg token "$SELLAPP_PREVIEW_TOKEN" \
    '. + {preview_token:$token}' <<< "$renewal_body")
  confirmation_json=$(api POST "/v2/subscriptions/${SELLAPP_SUBSCRIPTION_ID}/actions/change-renewal-date/confirm" \
    --header 'Content-Type: application/json' \
    --header "Idempotency-Key: ${SELLAPP_IDEMPOTENCY_KEY}" \
    --data "$confirm_body")
  jq '.data | {id, product_subscription_id, action, status, provider}' <<< "$confirmation_json"
else
  printf '%s\n' 'No supported preview was created; no confirmation request was sent.'
fi

Where supported, a successful 200 response means the action was saved. The payment provider may still be processing it; check its status and later signed events.

The confirmation response contains data.id (action ID), data.product_subscription_id, data.action: "shift_billing_date", data.status, and the actual provider name. The current Stripe/PayPal flows do not return this successful renewal-date result.

After a timeout, retain confirm_body and SELLAPP_IDEMPOTENCY_KEY and retry that exact confirmation. Do not create a new key for the same uncertain change. A different change needs a new preview and key. Fix 422 policy or validation errors before retrying; sending the same invalid input again will not help.

4. Cancel a disposable subscription deliberately

Cancellation is optional and affects future billing. Check cancellation capability first. This example schedules cancellation at the end of the current period; it does not request an immediate refund.

Schedule period-end cancellation
api GET "/v2/subscriptions/${SELLAPP_SUBSCRIPTION_ID}/capabilities" \
  | jq -e '.data.capabilities.cancel_at_period_end.status | . == "available" or . == "seller_only"'

api PATCH "/v2/subscriptions/${SELLAPP_SUBSCRIPTION_ID}/cancel" \
  --header 'Content-Type: application/json' \
  --header "Idempotency-Key: founder-memo-${SELLAPP_SUBSCRIPTION_ID}-cancel-v1" \
  --data '{"cancel_at_period_end":true}' | jq '.data'

The cancellation endpoint returns the subscription resource with 200. Keep the same key if this request loses its response. A future, distinct cancellation needs a new key. See Idempotency for where to send keys, how long they are kept, and what happens on a retry.

On this page