Guides

Quickstart: first subscription checkout

Create Founder memo circle, prepare webhook delivery, and take Natya from product creation to subscription checkout.

Take one purchase all the way through: Natya Sadella buys Founder memo circle 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/invoice 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_SLUG='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_SLUG}" \
    --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 invoice 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: "Founder memo 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 Founder memo circle
product_json=$(api POST /v2/products \
  --header 'Content-Type: application/json' \
  --data '{
    "title":"Founder memo circle",
    "description":"A monthly collection of annotated operating memos.",
    "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":"One operating memo each month; access is provisioned by our team.",
    "deliverable":{"types":["MANUAL"],"data":{"stock":null,"comment":"We will send your reading-room invitation."}},
    "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":"Founder memo circle","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 invoice with the initial one-time price by accident. The MANUAL deliverable means your team or integration must give the customer reading-room access; a successful API response does not do that work for you.

5. Create Natya's invoice and checkout session

Create the purchase and obtain its actual checkout URL
invoice_json=$(api POST /v2/invoices \
  --header 'Content-Type: application/json' \
  --data "$(jq -nc --arg variant "$SELLAPP_VARIANT_ID" '{
    customer_email:"natya.sadella@example.com",
    payment_method:"STRIPE",
    product_variants:{($variant):{quantity:1}}
  }')")
SELLAPP_INVOICE_ID=$(jq -er '.data.id' <<< "$invoice_json")

checkout_json=$(api POST "/v2/invoices/${SELLAPP_INVOICE_ID}/checkout")
SELLAPP_CHECKOUT_URL=$(jq -er '.payment_url' <<< "$checkout_json")
printf '%s\n' "$SELLAPP_CHECKOUT_URL"

Invoice creation returns 201. Its URL, when supplied, is data.checkout. The checkout operation returns 201 for a new payment session or 200 when reusing an existing session. Its URL is the top-level payment_url.

Invoice response — abbreviated
{"data":{"id":9001,"subscription_id":null,"customer_information":{"email":"natya.sadella@example.com"},"status":{"status":{"status":"PENDING"}},"checkout":"https://checkout.stripe.com/c/pay/cs_example"}}
Checkout response — abbreviated, illustrative URL
{"message":"Existing payment session has been found.","payment_url":"https://checkout.stripe.com/c/pay/cs_example","invoice":{"id":9001,"status":{"status":{"status":"PENDING"}}}}

Open the URL printed by your request, not the illustrative URL above. Completing checkout can charge the configured payment method and start recurring billing. Never automatically rerun this tutorial after a timeout: catalog and invoice creation do not provide a general idempotency key. Use the saved IDs to check what was created, and keep the request IDs for debugging before trying again.

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":"natya.sadella@example.com"}}
}

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

Read back the purchase after checkout
api GET "/v2/invoices/${SELLAPP_INVOICE_ID}" \
  | jq '.data | {id, customer_information, status, subscription_id, product_variants}'

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.

After 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