API ReferenceSubscriptions

Overview

Manage recurring subscription lifecycle actions across supported providers.

Subscriptions are recurring product purchases. What you can change depends on the payment provider, the subscription's current state, and whether the action belongs to the seller or customer. Read capabilities immediately before showing or using pause, resume, cancellation, payment-method, plan, or renewal-date controls.

Stripe, PayPal, and Razorpay bill a subscription's renewals themselves. With any other payment method, such as crypto, Cash App, or a custom payment method, the store bills the subscription in house: its provider is internal, and SellApp collects each renewal itself by emailing the customer a payment request.

Lifecycle writes accept an Idempotency-Key header of up to 128 characters or JSON idempotency_key; the header takes precedence. Save one key per intended change and reuse it only with identical input. Replaying a key returns the action the first request recorded instead of starting a second one. Reusing a key with different input, or from a different actor, returns 422 under errors.subscription and changes nothing. Dismissals are the exception: the key is accepted but never replayed, so retrying a dismissal that already succeeded returns 422 because the action is no longer pending. Read endpoints ignore the header. A successful response can mean the provider is still processing the action, not that the change has finished.

The lifecycle endpoints use the SellApp subscription record ID in productSubscription. The compatibility cancel route can also resolve supported order IDs. Operation schemas define the current provider-neutral request and response fields.

Endpoints

Capability responses explain which actions are available for the current actor and provider. Some actions may be customer_only, seller_only, require a redirect, or be unsupported by the payment provider. customer_only describes who owns the action, not who may call the API. A store token can request a plan change or a payment method update on the customer's behalf. Auto-renewal is the exception: set_auto_renew is only available from the customer portal and has no API endpoint.

Buying a plan

You sell a plan by creating an invoice or an order for its variant. Both follow the same rules:

  • A cart can carry one subscription plan, and nothing else: a plan mixed with other products or with a second plan returns 422 on product_variants.{variant_id}.
  • The plan's quantity must be 1. Any other quantity returns 422 on quantity with Entered quantity must be equal to 1 for subscribable products.
  • A plan that offers a trial is priced at the trial: nothing for a free trial, the trial price for a paid one. A customer who already had a trial of that product is priced at the plan price instead. Send the IP address the buyer is shopping from as customer_ip. Without it, only customer_email is checked, so a buyer could take another trial with a new email address.
  • When two requests race for the same trial, one can be refused with 422 on invoice, with Your checkout is already being processed. Please try again in a moment. or You have already used the trial for this product. Refresh the checkout to see the plan price. Create the invoice or order again, and it is priced for the customer as they now stand.

A customer, matched by customer_email, can hold one subscription per plan, whichever payment method bills it. While they hold the plan in an active, trialing, past-due, or unpaid subscription, creating an invoice or order for it again returns 422 on invoice. An active or trialing subscription, or a past-due one the store bills in house that isn't on hold, answers You already have an active subscription to this plan. You can manage it from your customer portal. A past-due or unpaid Stripe, PayPal, or Razorpay subscription answers with the renewal still owed instead, where {store} is the store's name:

  • Stripe: You already have this subscription. Its renewal is unpaid, so pay it from the payment email you received, or contact {store} for help.
  • Razorpay: You already have this subscription. Its renewal is unpaid, so update your payment method from the payment email you received, or contact {store} for help.
  • PayPal, which collects a missed payment no other way: You already have this subscription. Its renewal is unpaid, so contact {store} for help.

A subscription scheduled to end still counts while the customer can keep it. The message then points them to the customer portal instead: You already have this subscription. You can keep it from your customer portal. A subscription that can no longer be kept doesn't count. That includes a PayPal subscription cancelled at period end and a Razorpay trial cancelled at period end. The plan is the variant the subscription bills on now, so a customer who changed plans can buy their old plan again.

For a subscription the store bills in house that is on hold for an unpaid renewal, the message tells the customer to pay and resume it instead: You already have this subscription. You can pay and resume it from your customer portal.

Checkout doesn't repeat this check. If a pending invoice or order created earlier is paid for a plan the customer already holds, the payment is still recorded, SellApp cancels the second subscription, and subscription.signup_needs_attention tells you how that went.

A coupon discounts only the lines of its kind, set by the coupon's applies_to. A subscriptions coupon discounts the plan's first subscription_payments payments when the payment_method can carry the discount: Stripe, or a method the store bills in house. On PayPal or Razorpay, or on a weekly or daily Stripe plan discounted for more than one payment, the plan sells at its full price and the invoice or order is created without the coupon. These coupons return 422 on coupon instead:

  • A subscriptions coupon without a plan to discount: Coupon code MEMBER20 only applies to subscriptions.
  • A one_time coupon on a plan: Coupon code SPRING10 does not apply to subscriptions.
  • A subscriptions coupon on a plan that starts with a trial: Coupon code MEMBER20 does not apply to a subscription that starts with a trial.
  • A fixed amount that covers the plan's whole price: Coupon code MEMBER20 cannot make a subscription free.

Trials

A subscription plan can start with a free or paid trial, which you set as pricing.trial through the variant pricing endpoint. A subscription sold with a trial is TRIALING until the trial ends, and trial_ends_at records when that is. The plan price is billed from then on.

When the trial ends, the customer makes the first full payment, at the plan price:

  • On Stripe, PayPal, and Razorpay, the provider charges the payment method the customer authorised when they started the trial. SellApp emails the customer before the trial ends to say when they'll be charged.
  • On a subscription the store bills in house, SellApp creates the renewal order when the trial ends and emails the customer a payment request for it.

Once that payment is made, the subscription is ACTIVE and subscription.renewed is sent, as for any renewal. If it isn't paid, the subscription goes past due like any other unpaid renewal; see Failed payments.

While a subscription is trialing, some lifecycle actions work differently:

  • Pausing is refused on every provider. On a subscription the store bills in house, resuming and changing the renewal date are refused too. The capabilities response reports these actions as unsupported, and their endpoints return 422.
  • A plan change charges nothing during the trial. Where the provider can defer it, the new plan starts at the next renewal. Stripe switches the plan straight away, without proration.
  • On Razorpay, cancelling a trial at period end cancels it with Razorpay straight away, so it can't be kept afterwards. The customer keeps access until the trial ends.

Each customer gets one trial per product, across all of its plans. SellApp recognises a returning customer by email address, and by the IP address a trial was taken from in the last 30 days. An unpaid order that sold a trial counts while it can still be paid. A customer who already had a trial is not refused: checkout prices the plan at its full price instead.

Changing a plan's trial doesn't change the trial on orders that already sold one.

Discounts

A coupon or promotion whose applies_to is subscriptions discounts the first subscription_payments payments of a plan. The signup is the first of them, and each renewal after it bills the discounted price until those payments are used up. A subscription the store bills in house then bills the plan price it was sold at, even if you have changed the plan's price since.

Whether the discount is carried depends on who bills the subscription:

ProviderDiscount
Billed in house (internal)Carried for 1 to 36 payments on any plan.
StripeCarried by a single-use Stripe coupon SellApp adds to the subscription on the store's Stripe account. SellApp deletes that coupon once the checkout it was made for completes or expires; a subscription that already has the discount keeps it. More than one discounted payment needs a monthly or yearly plan, because Stripe counts a repeating discount in months; a weekly or daily plan discounted for more than one payment sells at its full price.
PayPalNot carried. The plan sells at its full price.
RazorpayNot carried. The plan sells at its full price.

On your storefront, a customer whose wallet balance covers the plan's full price can pay with the wallet instead, even beside a Stripe, PayPal, or Razorpay pick. That bills the subscription in house, so it is provider: internal and takes the discount. The API's wallet routes don't sell subscriptions.

A plan that starts with a trial takes no subscription discount. When the payment method can't carry the discount, checkout sells the plan at its full price without an error, so check the order's totals rather than assuming the discount applied.

  • Any plan change ends the discount, whichever way it changes. The plan change preview says so in preview_payload.discount_warning while discounted payments remain. On Stripe, SellApp removes its discount before it asks Stripe for the change, so the discount ends even if the change then fails or waits on a payment.
  • On Stripe, a paused or unpaid billing period still uses up a discounted payment, the same way Stripe counts its own coupons. A discount you added to the subscription in Stripe yourself is left alone.
  • Renewal and plan-change orders don't carry the coupon: their coupon_id is null. A coupon's limit and a promotion's max_redemptions count each subscription once, at signup.

Failed payments

When a renewal payment fails, the subscription's status says where collecting it stands:

  • PAST_DUE: the payment failed and is still being collected. The customer keeps access meanwhile.
  • UNPAID: the provider has stopped retrying. The customer loses access, such as license keys and community roles, until the payment is made.

Each provider gets there its own way:

ProviderPAST_DUEUNPAID
StripeStripe reports past_due, or paused because a trial ended without a payment methodStripe reports unpaid
PayPalA failed payment PayPal is still retryingPayPal suspended the subscription because payments failed
RazorpayRazorpay reports pendingRazorpay reports halted
Billed in house (internal)The renewal is still unpaid when its grace period, the days the customer has to pay before it counts as late, endsNever

A PayPal subscription you paused with the pause action is suspended at PayPal too. That stays a pause and doesn't change status. PayPal and Razorpay never end an unpaid subscription on their own. Stripe ends one after its last retry only if the Stripe account is set to cancel subscriptions whose payments fail. A subscription the store bills in house is cancelled or put on hold after its last payment reminder, depending on the store's payment retry policy. On hold, it stays PAST_DUE with paused_at set and no access, until the customer pays the renewal from the store's customer portal. Webhook payloads don't carry paused_at: an on-hold subscription shows up there as the paused_for_nonpayment reason, and you read paused_at from the API.

What you can do with it:

  • A Stripe, PayPal, or Razorpay subscription that is PAST_DUE or UNPAID can only be cancelled immediately, or, on Razorpay, have its payment method updated too. Its capabilities report every other action as unsupported, with the customer_reason This subscription has a payment that is still owed, so it can only be cancelled for now. Their endpoints return 422 with the seller_reason under errors.subscription.
  • Cancelling it can refund the last payment in full, but not pro-rated, meaning a refund of only the unused part of the current period, because that period was never paid for. refund_last_payment: true with pro_rated_refund: true returns 422 under errors.subscription with A pro-rated refund isn't available while this subscription has a payment that is still owed. Choose a full refund or no refund instead. Send pro_rated_refund: false for a full refund, or leave refund_last_payment off.
  • A PayPal subscription that is UNPAID isn't collected on its own: PayPal doesn't retry it again, and the customer can't pay the missed payment from a payment page. To give the customer access again, reactivate the subscription in your PayPal account. PayPal then bills the unpaid amount with the next payment.
  • A Razorpay subscription that is PAST_DUE or UNPAID can also update its payment method; capabilities report it customer_only instead of unsupported. Razorpay then charges the unpaid invoice itself on a PAST_DUE subscription. On an UNPAID one, the subscription runs again on the new card, but the invoices it raised while halted are not charged automatically, so charge them from the Razorpay dashboard.
  • A past-due subscription the store bills in house can only be cancelled immediately too. One on hold comes back only when the customer pays the unpaid renewal from the customer portal, and the API can't take that payment. Capabilities report resume as customer_only to a store token and as unsupported on the customer portal API. Both resume endpoints return 422 for it; the customer portal API's message is Pay the unpaid renewal from your customer portal to resume this subscription.

These refusals last until the payment is made or the subscription is cancelled, so don't retry them unchanged. Read capabilities again after payment_recovered.

A subscription_access entitlement follows the same rule: it is active while the subscription is past due, and suspended while it is unpaid, paused, or on hold.

SellApp tells you through subscription.renewal_needs_attention: past_due when a subscription falls past due, unpaid when a Stripe, PayPal, or Razorpay subscription goes unpaid, whether straight away or after past_due, payment_recovered when the payment is made, and, on Razorpay, resumed_without_payment when a halted one runs again on a new card without its unpaid invoices being paid. A subscription that ends because a payment went unpaid sends subscription.cancelled with reason: "nonpayment". Each is sent once per unpaid renewal, when SellApp sees the status change. The exception is unpaid: a subscription that comes back to PAST_DUE while the payment is still owed, such as an unpaid PayPal subscription you reactivate whose payment then fails again, sends unpaid again if it goes unpaid again. A subscription can still reach PAST_DUE or UNPAID without one, for example after a PayPal failure SellApp only hears about days later. So read the subscription's status when you need to know whether the customer has access.

Deliveries can repeat or arrive out of order. Deduplicate on the envelope's top-level id. Automatic retries resend the same id, but a delivery you replay by hand gets a new one, and so does each webhook channel, so also compare data.status and data.updated_at with what you have already processed, so a late past_due doesn't undo a payment_recovered.

Subscription webhooks

Seven webhook events describe a subscription's lifecycle. subscription.created, subscription.renewed, and subscription.cancelled carry the subscription record itself, including trial_ends_at, and subscription.cancelled adds why it ended. The other four events carry a lifecycle action, the reason a renewal needs attention, or the reason a signup needs the seller's decision. All of them use the standard SellApp webhook envelope (id, created_at, event, version, data, store) and are signed with the store's webhook secret when one is configured. Deduplicate on the top-level id: data.id is the subscription or action ID, which repeats across events. The webhooks guide covers delivery and signature verification, and the Webhook Channels API lets you subscribe a destination to them.

Note

Webhook payloads format every timestamp in data as Y-m-d H:i:s in UTC (for example 2026-08-14 09:12:44), while the API returns ISO-8601 for the same field names. Parse them separately. This is existing behaviour and applies to every subscription.* event. The envelope's own created_at is ISO-8601.

subscription.cancelled

Sent when a subscription is cancelled. data is the subscription.created payload plus reason:

  • nonpayment: the subscription ended because a renewal went unpaid. That happens when a subscription the store bills in house runs out of reminders and its payment retry policy cancels it, or when Stripe cancels a subscription after its last retry.
  • null: every other cancel, including one you or the customer make while a payment is owed.
Example subscription.cancelled payload
{
  "id": "9d0f88a1-07c5-472a-9a75-8a45d7122396",
  "created_at": "2026-09-22T00:07:32.000000Z",
  "event": "subscription.cancelled",
  "version": "1",
  "data": {
    "id": 123,
    "provider": "stripe",
    "provider_subscription_id": "sub_1QwertyExample",
    "provider_customer_id": "cus_Example123",
    "provider_customer_email": "maya.chen@example.com",
    "status": "CANCELED",
    "current_period_start": "2026-09-01 00:00:00",
    "current_period_end": "2026-10-01 00:00:00",
    "trial_ends_at": null,
    "cancel_at_period_end": false,
    "metadata": {},
    "created_at": "2026-03-01 00:00:00",
    "updated_at": "2026-09-22 00:07:31",
    "store_id": 1,
    "product_variant_id": 99,
    "original_invoice_id": 456,
    "reason": "nonpayment"
  },
  "store": 1
}

subscription.action

data is the subscription action object the lifecycle endpoints return, with the same keys and webhook timestamp formatting. SellApp sends this event when a customer-initiated action reaches confirmed, or when any action other than a plan change reaches failed or expired, regardless of who started it. Confirmed actions started by a seller, a store token, or SellApp itself are not sent. The event is only sent for the transition out of pending, so a resolved action is never sent twice.

Example subscription.action payload
{
  "id": "a052ab90-581d-49b6-b8ab-f62cde3dca76",
  "created_at": "2026-08-14T09:12:45.000000Z",
  "event": "subscription.action",
  "version": "1",
  "data": {
    "id": 4821,
    "product_subscription_id": 123,
    "subscription_id": "sub_1QwertyExample",
    "action": "pause",
    "status": "confirmed",
    "provider": "stripe",
    "proration_order_id": null,
    "error": null,
    "customer_message": null,
    "confirmed_at": "2026-08-14 09:12:44",
    "failed_at": null,
    "created_at": "2026-08-14 09:12:41",
    "updated_at": "2026-08-14 09:12:44"
  },
  "store": 1
}

subscription.plan_change_needs_attention

The plan-change counterpart of subscription.action, with the same action payload. A change_plan action that reaches failed or expired is sent here instead of on subscription.action, so a destination can handle plan-change failures separately. Subscribe to both events to receive every failure in one place.

Example subscription.plan_change_needs_attention payload
{
  "id": "ae02a8dc-fc77-40c6-9acf-5c773440b806",
  "created_at": "2026-08-14T11:02:08.000000Z",
  "event": "subscription.plan_change_needs_attention",
  "version": "1",
  "data": {
    "id": 4832,
    "product_subscription_id": 124,
    "subscription_id": null,
    "action": "change_plan",
    "status": "failed",
    "provider": "internal",
    "proration_order_id": 88231,
    "error": "The proration payment for the new plan was never completed.",
    "customer_message": null,
    "confirmed_at": null,
    "failed_at": "2026-08-14 11:02:07",
    "created_at": "2026-08-13 16:40:15",
    "updated_at": "2026-08-14 11:02:07"
  },
  "store": 1
}

subscription.renewal_needs_attention

Sent when a renewal needs the seller's attention: its payment failed, a failed payment was paid after all, or the renewal stopped in a way only the seller can settle. past_due, payment_recovered, and payment_refunded are sent whichever provider bills the subscription, and unpaid for Stripe, PayPal, and Razorpay subscriptions, so provider-billed integrations should subscribe to this event too. resumed_without_payment is Razorpay-only. The other reasons only apply to subscriptions the store bills in house. data is the subscription.created payload (the sixteen keys in the example below) plus four keys that explain the reason, so an integration that already parses subscription.created can parse this event as well.

Example subscription.renewal_needs_attention payload
{
  "id": "5a6287d0-54cc-47fc-9c73-5417ccb0ba4d",
  "created_at": "2026-09-01T01:04:13.000000Z",
  "event": "subscription.renewal_needs_attention",
  "version": "1",
  "data": {
    "id": 123,
    "provider": "stripe",
    "provider_subscription_id": "sub_1QwertyExample",
    "provider_customer_id": "cus_Example123",
    "provider_customer_email": "maya.chen@example.com",
    "status": "PAST_DUE",
    "current_period_start": "2026-09-01 00:00:00",
    "current_period_end": "2026-10-01 00:00:00",
    "trial_ends_at": null,
    "cancel_at_period_end": false,
    "metadata": {},
    "created_at": "2026-03-01 00:00:00",
    "updated_at": "2026-09-01 01:04:12",
    "store_id": 1,
    "product_variant_id": 99,
    "original_invoice_id": 456,
    "reason": "past_due",
    "reason_label": "A subscription renewal is past due",
    "summary": "The renewal for a subscription is still unpaid, so the subscription is now past due.",
    "renewal_order_id": null
  },
  "store": 1
}
  • reason: why the renewal needs attention. Branch on this field rather than on reason_label or summary.
    • past_due: the renewal is still unpaid and the subscription is now PAST_DUE. The payment is still being collected, so there is nothing to do yet.
    • unpaid: the subscription went UNPAID because the provider stopped retrying, either straight away or after past_due. The customer's access is paused until they pay. It's sent once per unpaid bill, and again if the subscription comes back to PAST_DUE with the bill still owed and then goes unpaid again. Only Stripe, PayPal, and Razorpay subscriptions reach it.
    • payment_recovered: a renewal that was past due or unpaid was paid, and the subscription is active again.
    • resumed_without_payment: a halted Razorpay subscription ran again after the customer updated their card, but the invoices it raised while halted are not charged automatically. Charge them from the Razorpay dashboard. Only Razorpay subscriptions reach it.
    • payment_refunded: an order for the subscription was refunded, and the subscription itself was left running. Cancel it if the refund was meant to end it.
    • paused_for_nonpayment: the subscription was put on hold because its renewal went unpaid. The customer can pay and resume it from the customer portal.
    • payment_after_closure: a renewal payment arrived after that renewal had been given up, so nothing was renewed or delivered.
    • completion_failed: a renewal was paid, but finishing it didn't complete, so the delivery may not have gone out.
    • period_mismatch: a second renewal payment arrived for a period that was already paid, so the renewal date was left alone.
    • plan_change_after_closure: the payment for a plan change arrived after that change had expired, so the plan was left as it was.
    • renewal_not_collectable: the renewal couldn't be billed because the plan or the order it bills from is no longer usable, so the customer was never asked to pay.
  • reason_label: the short seller-facing label for that reason, such as A renewal could not be billed.
  • summary: a one-sentence, seller-facing explanation of what happened.
  • renewal_order_id: the order the renewal was billed on, or null when there was none. paused_for_nonpayment, renewal_not_collectable, unpaid, and resumed_without_payment never have one, and neither do past_due and payment_recovered on a Stripe, PayPal, or Razorpay subscription, because the provider bills the renewal.

subscription.signup_needs_attention

Sent when a signup leaves the seller something to decide. That happens when a customer signs up again for a plan they already hold, whichever provider bills either subscription, or when an order sold a trial but doesn't say how long the trial is or what it costs, so no subscription could be started. The payment is captured and the order still completes, so the seller decides what happens next, such as refunding the order. data names the order and the plan it bought, not a subscription.

Example subscription.signup_needs_attention payload
{
  "id": "c5f9d626-5fac-488b-9a74-5895a7b1f4aa",
  "created_at": "2026-08-15T10:41:27.000000Z",
  "event": "subscription.signup_needs_attention",
  "version": "1",
  "data": {
    "order_id": 88240,
    "product_variant_id": 99,
    "reason": "duplicate",
    "reason_label": "Signup paid for a plan the customer already has",
    "summary": "A customer paid for a plan they already subscribe to, so the second subscription was cancelled. Decide whether to refund the order.",
    "charge": null
  },
  "store": 1
}
  • order_id: the order of the signup that needs attention.
  • product_variant_id: the subscription plan the order bought.
  • reason: what happened. Branch on this field rather than on reason_label or summary.
    • duplicate: the customer paid for a plan they already subscribe to, so the second subscription was cancelled. Their existing subscription is not affected. Decide whether to refund the order.
    • duplicate_free: the customer signed up again, at no charge, for a plan they already subscribe to, so the second subscription was cancelled. Nothing was charged, so there is nothing to refund.
    • duplicate_still_live: the customer signed up again for a plan they already subscribe to, but the second subscription could not be cancelled at the payment provider. SellApp keeps retrying. If it stays active there, cancel it at the provider and decide whether to refund the order.
    • duplicate_charged: a duplicate subscription that was cancelled in SellApp was charged again at the payment provider, and no order was booked for that charge. order_id is the duplicate's original order, and charge names the provider charge to refund. Refund it at the provider and make sure the subscription is cancelled there.
    • missing_trial_terms: the order sold a trial but doesn't record how long the trial is or what it costs, so no subscription was started. Start the subscription by hand, or refund the order. This only happens on subscriptions the store bills in house.
  • reason_label: the short seller-facing label for that reason.
  • summary: a short, seller-facing explanation of what happened and what to do.
  • charge: the provider charge to refund when reason is duplicate_charged, and null for every other reason. It has id, the charge's own identifier at the provider, plus amount_cents and currency. Those two are null when the provider didn't report the amount.

Field names across the subscription events

The two payload families use different names for the same identifiers. subscription.action and subscription.plan_change_needs_attention follow the API's action object. subscription.created, subscription.renewed, subscription.cancelled, and subscription.renewal_needs_attention follow the subscription webhook shape. An integration that parses both needs to map them accordingly.

ValueAction payloadsSubscription payloads
The SellApp subscription IDproduct_subscription_idid
The provider subscription IDsubscription_idprovider_subscription_id
The provider customer ID—provider_customer_id
The customer email—provider_customer_email
The originating order ID—original_invoice_id

The subscription API returns those last three as provider_customer_id (and the deprecated customer_id), customer_email, and invoice_id. Only the customer ID is a provider value, so it is null for a subscription the store bills in house, like provider_subscription_id. The other two are SellApp values: the email comes from checkout, and the order ID is resolved from the subscription's first invoice line, which every in-house subscription has.

See Handle subscriptions.

On this page