Public API
Push products, releases and files straight into your store — from CI, a script, or any workflow tool. No dashboard required.
Use it from Claude Code
Install our official Claude Code skill and shipping a release becomes a sentence in your dev session. No curl, no JSON, no orchestration.
curl -fsSL https://sell.thinkspark.dev/skills/sell-push/install.sh | bashRestart Claude Code once so it picks up the new skill, then in any session:
> Pousse cette app sur ma boutique Sell.
Path: ./dist/my-app-1.0.0.zip
Name: My App, prix 1900, externalId: my-app
Description dans ./README.md. Publie.Claude handles the 7 API calls under the hood: create or upsert the product, presign the upload, PUT the file to R2, register it against a new release, publish. Idempotent by externalId, so re-running with a new version just ships an update.
Not on Claude Code? The REST API below works from anything that speaks HTTP — CI, Zapier, a Bash one-liner, a Python script.
Quick start
Create a key in Settings → API keys, then push your first product end-to-end:
# 1. Create the product (upsert by externalId)
curl https://sell.thinkspark.dev/api/v1/products \
-H "Authorization: Bearer tsk_live_…" \
-H "Content-Type: application/json" \
-d '{
"externalId": "my-app",
"name": "My App",
"priceCents": 2900,
"currency": "USD",
"productType": "DIGITAL_DOWNLOAD"
}'
# 2. Get a presigned upload URL
curl https://sell.thinkspark.dev/api/v1/uploads \
-H "Authorization: Bearer tsk_live_…" \
-H "Content-Type: application/json" \
-d '{"fileName":"my-app-1.0.0.zip","contentType":"application/zip","contentLength":1048576}'
# → { "url": "https://r2…?signed", "key": "sellers/…/api/…-my-app-1.0.0.zip" }
# 3. Upload the file directly to R2
curl -X PUT --data-binary @my-app-1.0.0.zip \
-H "Content-Type: application/zip" \
"<presigned-url>"
# 4. Create a release and register the file against it
curl https://sell.thinkspark.dev/api/v1/products/my-app/releases \
-H "Authorization: Bearer tsk_live_…" \
-H "Content-Type: application/json" \
-d '{"version":"1.0.0","externalId":"git-tag-v1.0.0"}'
curl https://sell.thinkspark.dev/api/v1/products/my-app/releases/git-tag-v1.0.0/files \
-H "Authorization: Bearer tsk_live_…" \
-H "Content-Type: application/json" \
-d '{"storageKey":"<key-from-step-2>","fileName":"my-app-1.0.0.zip","fileSize":1048576,"mimeType":"application/zip"}'
# 5. Publish the release (also marks it current) and publish the product
curl -X POST https://sell.thinkspark.dev/api/v1/products/my-app/releases/git-tag-v1.0.0/publish \
-H "Authorization: Bearer tsk_live_…"
curl -X POST https://sell.thinkspark.dev/api/v1/products/my-app/publish \
-H "Authorization: Bearer tsk_live_…"Authentication
Every request takes a Bearer token in the Authorization header. Two environments share the same DB and code path:
tsk_live_…— writes hit your real catalogue. Use this from CI when you're confident.tsk_test_…— sandbox. Products created with a test key are forced tostatus=DRAFTand never publishable through the API. Same DB, zero risk to your public store.
Keys are shown once at creation. Lost a key? Revoke it and create a new one — no recovery.
Authorization: Bearer tsk_live_AB23K9NPQR...Idempotency via externalId
Every write endpoint accepts an externalId field. Pass a stable identifier you own (a Git tag, a row id from your CMS, a Notion page id…). When the same externalId is sent again, the resource is updated instead of duplicated — perfect for CI retries, Zapier replays and idempotent pipelines.
externalId is scoped to its parent: per seller for products, per product for releases. Two different sellers can both use my-app without collision.
Error shape
Every non-2xx response carries the same JSON envelope:
{
"error": {
"type": "invalid_request",
"code": "missing_field",
"message": "Subscription products require a billingInterval",
"param": "billingInterval"
}
}authentication_required· 401 — missing or invalid keypermission_denied· 403 — key lacks the required scoperate_limited· 429 — slow down (see below)not_found· 404 — unknown id or externalIdconflict· 409 — duplicate externalId / sluginvalid_request· 400 — validation failedinternal_error· 500 — our bad. Retry with backoff.
Rate limits
60 requests per minute per API key, burst of 60. A noisy CI key won't starve your other keys. On a 429 the response body tells you when to retry.
Products
/api/v1/productsproducts:readList products. Cursor-paginated via ?cursor=<last-id>&limit=20. Filter with ?status=PUBLISHED or ?externalId=….
/api/v1/productsproducts:writeCreate or upsert (if externalId matches an existing product). Test-mode keys force status=DRAFT.
/api/v1/products/{ref}products:read{ref} may be the Thinkspark id or your externalId.
/api/v1/products/{ref}products:writePartial update — only the fields you send are written.
/api/v1/products/{ref}/publishproducts:writeGates: priceCents > 0; DIGITAL_DOWNLOAD needs a current release with at least one file; SUBSCRIPTION needs a billingInterval. Refused for test-mode keys.
/api/v1/products/{ref}/archiveproducts:write/api/v1/products/{ref}products:writeSoft-delete — archives the product to preserve past orders. Use POST .../archive for the explicit verb.
Releases & files
/api/v1/products/{ref}/releasesproducts:read/api/v1/products/{ref}/releasesreleases:writeCreate or upsert by externalId. Accepts publish: true to auto-promote after registering files, or markCurrent: true to swap the active version for rollback.
/api/v1/products/{ref}/releases/{releaseRef}/publishreleases:writeStamps publishedAt and promotes to isCurrent=true, demoting the prior current release atomically.
/api/v1/products/{ref}/releases/{releaseRef}/filesproducts:read/api/v1/products/{ref}/releases/{releaseRef}/filesreleases:writeRegister a file you've PUT to R2 via the presigned URL. The storageKey must live under your seller namespace (sellers/<sellerId>/api/...) — keys outside it are rejected.
Uploads
/api/v1/uploadsuploads:writeReturns a 15-minute presigned PUT URL. Body: { fileName, contentType, contentLength, folder? }. After uploading, register the file against a release.
Outbound webhooks
Subscribe an HTTP endpoint to events from your store. Configure one or more in Dashboard → Webhooks. We POST a JSON envelope with HMAC-SHA256 signing — verify it before trusting the body.
Event types
order.paid— Buyer completed a one-shot purchaseorder.refunded— Refund issued (full or partial)release.published— A new release was promoted to currentsubscription.created— Recurring subscription startedsubscription.updated— Subscription period renewed or modifiedsubscription.canceled— Subscription canceled (immediate or at period end)
Request shape
POST https://your-app.com/webhooks/sell
content-type: application/json
user-agent: ThinksparkSell-Webhooks/1.0
x-sell-event: order.paid
x-sell-event-id: evt_a1b2c3…
x-sell-signature: t=1779800000,v1=<hex>
{
"id": "evt_a1b2c3…",
"type": "order.paid",
"created": 1779800000,
"livemode": true,
"data": {
"orderId": "clk…",
"productId": "clk…",
"customerEmail": "buyer@example.com",
"amountCents": 1900,
"currency": "USD",
"paidAt": "2026-05-26T12:34:56.000Z"
}
}Verifying the signature (Node.js)
import crypto from "node:crypto";
// In your /webhooks/sell route, read the RAW body — never parse it first.
function verify(rawBody, header, secret) {
// header looks like: t=1779800000,v1=ab12cd...
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("="))
);
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + "." + rawBody)
.digest("hex");
// Constant-time compare to avoid timing attacks
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex")
);
}Retries & auto-pause
- Up to 3 attempts on 5xx or network failure, with backoff (0s, 2s, 8s).
- Any 4xx response is final — we treat it as "don't bother retrying".
- Each request has a 10s timeout. Return 2xx fast; queue heavy work async.
- After 10 consecutive failures, the endpoint is auto-paused — reactivate it once you've fixed the cause.
Need an endpoint that isn't here yet? Tell us what you're building.