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.
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_TOKENCapabilities tell you which actions this subscription supports and who can
perform them. A 200 response can include:
{"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.
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.'
fiWhen 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):
{"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.
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.'
fiWhere 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.
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.