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.
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.
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.
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:
{"data":{"id":120,"title":"Founder memo circle","visibility":"HIDDEN","variants":[]}}{"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.
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
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.
{"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"}}{"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:
{
"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:
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.