Guides

Advanced: subscription checkout

Create Design kit membership, prepare webhook delivery, and take Maya from product creation to subscription checkout.

Take one purchase all the way through: Maya Chen buys Design kit membership for $19.99 per month. You will set up webhooks, create the product, and get a checkout link. The commands carry returned IDs forward automatically; the illustrative responses use product 120, variant 4321, order 9001, and SellApp subscription 55. Your IDs will differ.

1. Prepare a dedicated store

There is no separate SellApp API sandbox. These requests create real records and payment-provider objects. Use a dedicated store, a configured Stripe connection that supports subscriptions, and provider test credentials only where your store's integration supports them. Do not complete a real payment unless you intend to pay.

You need Bash, cURL for HTTP requests, jq for reading JSON, and a key with listing, invoice, and webhook abilities. Subscription lifecycle requests also use invoice, not a separate subscription ability. The account needs the corresponding store permissions.

Before continuing, deploy a receiver at a public HTTPS URL, configure the store's signing secret in Developer settings, and securely give the same secret to the receiver. Follow the crash-safe receiver flow. Keep secret keys out of browser code and source control.

Run once in the same Bash session
set -euo pipefail
export SELLAPP_API_BASE_URL='https://sell.app/api'
export SELLAPP_API_KEY='replace-me'
export SELLAPP_STORE='launch-lab'
export SELLAPP_WEBHOOK_URL='https://your-server.example/sellapp/webhooks'

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}" \
    --header 'Accept: application/json' \
    --dump-header /dev/stderr "$@"
}

api GET '/v2/products?limit=1' | jq '.data'

Replace replace-me with your key, launch-lab with your store slug, and the .example webhook URL with your own reachable endpoint. Expect 200 from the read request; an empty array is valid. Response headers go to stderr so you can retain X-Request-ID without corrupting the JSON output.

2. Register and test the webhook first

Do this before creating an order or opening checkout, otherwise you can miss the events you want to observe. This creates a new channel; if the URL already has a channel, retrieve it and update its filter instead.

Register the receiver and prove it acknowledges a signed test
channel_json=$(api POST /v2/webhook-channels \
  --header 'Content-Type: application/json' \
  --data "$(jq -nc --arg url "$SELLAPP_WEBHOOK_URL" '{
    name: "Design kit receiver",
    url: $url,
    allowed_notifications: ["order.created", "order.paid", "order.completed", "subscription.created"]
  }')")
SELLAPP_WEBHOOK_CHANNEL_ID=$(jq -er '.data.id' <<< "$channel_json")
jq -e '.data.signing_secret_configured == true' <<< "$channel_json"

api POST "/v2/webhook-channels/${SELLAPP_WEBHOOK_CHANNEL_ID}/test" \
  --header 'Content-Type: application/json' \
  --data '{"event":"order.created"}' | jq -e '.data.status == "delivered"'

The channel response is 201. The test is 200 with data.status: "delivered". Confirm the receiver durably stored the test payload and pending work. Test payload IDs are synthetic and will not match the sale below.

3. Create the product and variant

HIDDEN keeps the product off normal storefront listings; it is not an is_draft flag or a security boundary. The API rejects writes to is_draft. Do not share checkout links until setup is complete.

Create Design kit membership
product_json=$(api POST /v2/products \
  --header 'Content-Type: application/json' \
  --data '{
    "title":"Design kit membership",
    "description":"New design templates each month.",
    "visibility":"HIDDEN"
  }')
SELLAPP_PRODUCT_ID=$(jq -er '.data.id' <<< "$product_json")

variant_json=$(api POST "/v2/products/${SELLAPP_PRODUCT_ID}/variants" \
  --header 'Content-Type: application/json' \
  --data '{
    "title":"Monthly membership",
    "description":"Monthly design files delivered by our team.",
    "deliverable":{"types":["MANUAL"],"data":{"stock":null,"comment":"We will send your design files."}},
    "pricing":{"humble":false,"price":{"price":1999,"currency":"USD"}},
    "payment_methods":["STRIPE"]
  }')
SELLAPP_VARIANT_ID=$(jq -er '.data.id' <<< "$variant_json")

Both requests return 201. Selected fields from their responses:

Product response — abbreviated
{"data":{"id":120,"title":"Design kit membership","visibility":"HIDDEN","variants":[]}}
Variant response — abbreviated
{"data":{"id":4321,"product_id":120,"title":"Monthly membership","payment_methods":["STRIPE"]}}

4. Configure monthly recurring pricing

The variant starts with a one-time price. This next request changes it to a monthly subscription. USD amounts use cents: send 1999 for $19.99, not 19.99.

Set the recurring price before checkout
api PUT "/v2/products/${SELLAPP_PRODUCT_ID}/variants/${SELLAPP_VARIANT_ID}/pricing" \
  --header 'Content-Type: application/json' \
  --data '{
    "pricing":{
      "type":"SUBSCRIPTION",
      "humble":false,
      "price":{"price":1999,"currency":"USD"},
      "frequency":{"value":1,"interval":"MONTH"}
    },
    "payment_methods":["STRIPE"]
  }' | jq -e '.data.pricing.type == "SUBSCRIPTION"'

Expect 200. Resolve provider/configuration errors before continuing; do not create the order with the initial one-time price by accident. The MANUAL deliverable means your team or integration must give the customer design files; a successful API response does not do that work for you.

5. Create Maya's order and checkout session

Create the purchase and obtain its actual checkout URL
order_json=$(api POST /v2/orders \
  --header 'Idempotency-Key: launch-lab-maya-order-001' \
  --header 'Content-Type: application/json' \
  --data "$(jq -nc --arg variant "$SELLAPP_VARIANT_ID" '{
    customer_email:"maya@example.com",
    payment_method:"STRIPE",
    product_variants:{($variant):{quantity:1}}
  }')")
SELLAPP_ORDER_ID=$(jq -er '.data.id' <<< "$order_json")

checkout_json=$(api POST "/v2/orders/${SELLAPP_ORDER_ID}/checkout" \
  --header 'Idempotency-Key: launch-lab-maya-checkout-001')
SELLAPP_CHECKOUT_URL=$(jq -er '.data.payment.checkout_url' <<< "$checkout_json")
printf '%s\n' "$SELLAPP_CHECKOUT_URL"

Order creation returns 201; checkout returns 201 for a new payment session or 200 when reusing an existing session. Both return an order under data and include data.payment.checkout_url when a URL is available.

Order response — abbreviated
{"data":{"id":9001,"status":"PENDING","customer":{"email":"maya@example.com"},"payment":{"checkout_url":"https://checkout.stripe.com/c/pay/cs_example"},"totals":{"currency":"USD","total_cents":1999}}}
Checkout response — abbreviated, illustrative URL
{"data":{"id":9001,"status":"PENDING","payment":{"checkout_url":"https://checkout.stripe.com/c/pay/cs_example"}}}

Open the URL printed by your request, not the illustrative URL above. Completing checkout can charge the configured payment method and start recurring billing. Use a unique idempotency key for each intended order and a separate key for checkout. If a response is lost, retry the same operation with the same key and identical body. Do not reuse these illustrative keys for another purchase. See idempotency for retention and conflicts.

6. Verify payment, completion, and subscription

A selected portion of the signed order.completed payload can look like this:

Order webhook — abbreviated; verify the full raw body
{
  "id":"01992a65-e064-71ba-b38f-902b7966a6be",
  "created_at":"2026-08-30T12:05:00.000000Z",
  "event":"order.completed",
  "version":"1",
  "store":1,
  "data":{"id":9001,"subscription_id":55,"customer_information":{"email":"maya@example.com"}}
}

The top-level id identifies the delivery; data.id identifies the order. Match data.id with SELLAPP_ORDER_ID. Events can arrive out of order, and the subscription may be linked to the purchase a little later. Retrieve the order again:

Read back the purchase after checkout
api GET "/v2/orders/${SELLAPP_ORDER_ID}" \
  | jq '.data | {id, customer, status, line_items}'

order.paid means the order entered the paid state and fulfillment was queued. order.completed means SellApp entered its completed order state; it does not prove your receiver's work or every external notification has finished. Track processed delivery IDs so a repeated event does not repeat the work. Also make granting access safe to retry for the same order and variant.

Find the purchased variant in data.line_items by product_variant_id. After its subscription_id becomes available, use that integer SellApp ID (55 here), not a provider ID like sub_..., in the subscription lifecycle walkthrough. Review and cancel disposable recurring test subscriptions so they do not renew.

On this page