Sell
v1 · stable

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

One-liner install~/.claude/skills/sell-push/SKILL.md · idempotent

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 | bash

Restart 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 to status=DRAFT and 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 key
  • permission_denied · 403 — key lacks the required scope
  • rate_limited · 429 — slow down (see below)
  • not_found · 404 — unknown id or externalId
  • conflict · 409 — duplicate externalId / slug
  • invalid_request · 400 — validation failed
  • internal_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

GET/api/v1/productsproducts:read

List products. Cursor-paginated via ?cursor=<last-id>&limit=20. Filter with ?status=PUBLISHED or ?externalId=….

POST/api/v1/productsproducts:write

Create or upsert (if externalId matches an existing product). Test-mode keys force status=DRAFT.

GET/api/v1/products/{ref}products:read

{ref} may be the Thinkspark id or your externalId.

PATCH/api/v1/products/{ref}products:write

Partial update — only the fields you send are written.

POST/api/v1/products/{ref}/publishproducts:write

Gates: priceCents > 0; DIGITAL_DOWNLOAD needs a current release with at least one file; SUBSCRIPTION needs a billingInterval. Refused for test-mode keys.

POST/api/v1/products/{ref}/archiveproducts:write
DELETE/api/v1/products/{ref}products:write

Soft-delete — archives the product to preserve past orders. Use POST .../archive for the explicit verb.

Releases & files

GET/api/v1/products/{ref}/releasesproducts:read
POST/api/v1/products/{ref}/releasesreleases:write

Create or upsert by externalId. Accepts publish: true to auto-promote after registering files, or markCurrent: true to swap the active version for rollback.

POST/api/v1/products/{ref}/releases/{releaseRef}/publishreleases:write

Stamps publishedAt and promotes to isCurrent=true, demoting the prior current release atomically.

GET/api/v1/products/{ref}/releases/{releaseRef}/filesproducts:read
POST/api/v1/products/{ref}/releases/{releaseRef}/filesreleases:write

Register 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

POST/api/v1/uploadsuploads:write

Returns 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 purchase
  • order.refunded — Refund issued (full or partial)
  • release.published — A new release was promoted to current
  • subscription.created — Recurring subscription started
  • subscription.updated — Subscription period renewed or modified
  • subscription.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.