API reference
API reference
The same API the dashboard uses, with no private back doors. Versioned REST under /api/v1, authenticated with a scoped key you can restrict to one store or one product, rotate without an outage, and revoke in a click. 50 public endpoints today — each one named below, with the scope it wants, and all 50 described by an OpenAPI document you can import straight into Postman.
Request
curl https://<api-host>/api/v1/public/orders \ -H "Authorization: Bearer sellstamp_live_…" \ -G -d limit=50 RateLimit-Limit: 120 RateLimit-Remaining: 119 RateLimit-Reset: 47
Every response says where you stand in the budget.
Authentication
A bearer key, resolved by its hash on a unique index exactly as a session cookie is. The workspace comes from the key's own row, which is why a caller can never name a workspace and why isolation here is structural rather than a check somebody has to remember.
- The key
sellstamp_live_…orsellstamp_test_…, 32 random bytes from the same generator behind session cookies. Stored as a SHA-256 hash — the raw value exists only in the response that created it, is in no log, and support cannot recover it. Lost one? Revoke it and make another.- A test key is not a sandbox
- The two modes behave identically: same tables, same rows, same real side effects. Nothing in the product branches on the mode. It exists so a key in the wrong environment’s log is recognisable at a glance, and so you can revoke a test integration without touching a production one.
- The two refusals
- No key, a malformed header, an unknown key and a revoked key all return one byte-identical 401 — that is the only comparison an attacker holding no valid credential can make. A key that authenticates but lacks the scope gets a 403 naming the scope the route requires, never what your key holds.
- Rotation, not a second secret
- Rotating issues a new key with the same authority and puts the old one on a deadline — 24 hours by default, zero for an immediate cut-over after a leak, up to a week. Both rows show in the dashboard. Rotation is allowed even at your plan’s key limit: replacing a leaked credential must never be the thing a plan blocks.
- Expiry
- Create a key with an end date and past it, it returns the same generic 401 as a revoked one — and stops occupying a plan slot, because it authenticates nothing.
Scopes, and where they reach
A scope says what a key may do. Its store list says where. Both are chosen when the key is made; the scopes can be edited afterwards without changing the secret, so widening or narrowing an integration costs no redeploy.
- orders.read
- Read orders and their line items (GET /public/orders, GET /public/orders/:orderId)
- orders.refund
- Refund an order in full (POST /public/orders/:orderId/refund)
- products.read
- Read products and their variants (GET /public/products, GET /public/reviews, GET /public/shop, GET /public/changes, GET /public/products/:productId)
- products.write
- Create products (POST /public/products)
- products.update
- Edit existing products (PATCH /public/products/:productId)
- products.delete
- Archive products, taking them off sale (DELETE /public/products/:productId)
- customers.read
- Read the customer list and what each has bought (GET /public/customers, GET /public/customers/:email)
- coupons.write
- Create discount coupons (POST /public/coupons)
- licenses.read
- Read issued licenses (GET /public/licenses, POST /public/licenses/lookup)
- licenses.manage
- Suspend and resume licenses, reset their activations and release one device (GET /public/licenses/:licenseId/activations, POST /public/licenses/:licenseId/suspend, POST /public/licenses/:licenseId/resume, POST /public/licenses/:licenseId/reset-activations, POST /public/licenses/:licenseId/activations/:activationId/release)
- courses.read
- Read courses, their students and each student's progress (GET /public/courses, GET /public/courses/:courseId/students, GET /public/courses/:courseId/students/:studentId)
- courses.write
- Enrol students in a course and revoke their access (POST /public/courses/:courseId/students, POST /public/courses/:courseId/students/:studentId/revoke)
- community.read
- Read paid community access: who is in, who is waiting to connect, and why access ended (GET /public/community/members, GET /public/community/members/:accessId)
- loyalty.read
- Read loyalty members: points balances, tiers and each member's history (GET /public/loyalty/members, GET /public/loyalty/members/:accountId)
- loyalty.write
- Add or take away a customer's loyalty points, with a reason (large adjustments still need a person) (POST /public/loyalty/adjust)
- wishlists.read
- Read which products customers wishlist most, and how many are waiting for an alert (GET /public/wishlists/insight)
- apps.develop
- Manage your own marketplace apps: submit versions, read their logs, run conformance checks, create a test store (GET /public/developer/apps, PUT /public/developer/apps/:listingId/manifest, POST /public/developer/apps/:listingId/submit, GET /public/developer/apps/:listingId/logs, POST /public/developer/apps/:listingId/conformance, POST /public/developer/test-store)
Restrict it to a store
A key with no store list reaches every store in the workspace, including ones you add later. Name one and it reaches only that one. Name two and every list has to say which — a silently merged view of some of a workspace's stores is a number you would read as the whole workspace.
Or to a product
Narrower than a store and applied on top of it: that product, the orders containing it, and its licences. Such a key cannot create products — whatever it made would not be one of its own, so it could not read back the thing it just made.
A withheld store is a 404
Naming a store the key was not granted answers exactly as naming one that does not exist, which is what stops a restricted key from using the store field to enumerate the rest of the workspace.
The endpoints
50 of them, read from the OpenAPI document this API serves rather than typed here — which is the only reason this number is right. The scope list above is derived from the same place: a scope with no endpoint behind it would be a promise the API cannot keep.
- GET /public/orders
- Needs
orders.read. Your orders, newest first. Lists sales, so a test purchase you made to yourself is not in it — an integration syncing this into an accounting ledger must not import an order you made to yourself. - GET /public/orders/:orderId
- Needs
orders.read. One order, whole, line items included. No shop is named — the key’s own restrictions are the entire filter, so an order outside them is a 404. A test purchase fetched directly does come back, markedisTest: the row is not a secret, it is just not a sale. - POST /public/orders/:orderId/refund
- Needs
orders.refund. Refund an order in full. The same function behind the refund button, with its row lock and its ceilings — and narrower on purpose: full only, never as store credit, and it never withdraws a customer’s downloads or licences. That last one is a staff decision with its own permission, and no scope grants it. - GET /public/products
- Needs
products.read. Your catalogue with its variants. - POST /public/products
- Needs
products.write. Create a product. It runs the same function the dashboard and onboarding call, so your plan limit, the licensing checks and the slug rules apply here by construction rather than by this route remembering them. - GET /public/reviews
- Needs
products.read. - GET /public/shop
- Needs
products.read. - GET /public/changes
- Needs
products.read. - GET /public/products/:productId
- Needs
products.read. - PATCH /public/products/:productId
- Needs
products.update. Edit a product. Its own scope, notproducts.write, so a key that was granted “create” did not quietly become one that can reprice your catalogue. It runs the same function the edit form calls, so the slug history, the recorded list price, the forward-only licence rules and the plan checks all still apply. - DELETE /public/products/:productId
- Needs
products.delete. Take a listing off sale. It archives: there is no hard delete, because an order has to keep pointing at a real product row. The listing leaves the storefront and checkout and frees its plan slot; every existing order, download and licence is untouched, and you can restore it from the dashboard. - GET /public/customers
- Needs
customers.read. The customers of one store, and what each has spent net of refunds. Needs ashopIdunless your key is restricted to exactly one store. Deliberately narrower than your own customer page: no notes, no tags, no risk flags, no store credit, no payment providers, and no marketing-consent state. - GET /public/customers/:email
- Needs
customers.read. One customer and the orders they placed at that store. Same redactions as the list. The address is a path segment, so percent-encode it. - POST /public/coupons
- Needs
coupons.write. Create a discount code. Same normalisation and same per-store uniqueness as the dashboard’s own form. Editing and archiving a coupon are still dashboard-only. - GET /public/app
- No scope — an installed app’s own token, nothing else. What an installed app’s own install looks like from the app’s side, including its webhook signing secret. App tokens only — an API key has no install and gets a 401 here.
- GET /public/app/settings
- No scope — an installed app’s own token, nothing else.
- GET /public/app-data
- No scope — an installed app’s own token, nothing else.
- GET /public/app-data/:resourceType/:resourceId/:key
- No scope — an installed app’s own token, nothing else.
- PUT /public/app-data/:resourceType/:resourceId/:key
- No scope — an installed app’s own token, nothing else.
- DELETE /public/app-data/:resourceType/:resourceId/:key
- No scope — an installed app’s own token, nothing else.
- GET /public/developer/apps
- Needs
apps.develop. - PUT /public/developer/apps/:listingId/manifest
- Needs
apps.develop. - POST /public/developer/apps/:listingId/submit
- Needs
apps.develop. - GET /public/developer/apps/:listingId/logs
- Needs
apps.develop. - POST /public/developer/apps/:listingId/conformance
- Needs
apps.develop. - POST /public/developer/test-store
- Needs
apps.develop. - POST /public/app-data/bulk
- No scope — an installed app’s own token, nothing else.
- POST /public/app/triggers
- No scope — an installed app’s own token, nothing else.
- POST /public/app/rotate-secret
- No scope — an installed app’s own token, nothing else.
- POST /public/hooks
- No scope — an installed app’s own token, nothing else.
- GET /public/hooks
- No scope — an installed app’s own token, nothing else.
- DELETE /public/hooks/:id
- No scope — an installed app’s own token, nothing else.
- GET /public/community/members
- Needs
community.read. - GET /public/community/members/:accessId
- Needs
community.read. - GET /public/loyalty/members
- Needs
loyalty.read. - GET /public/loyalty/members/:accountId
- Needs
loyalty.read. - POST /public/loyalty/adjust
- Needs
loyalty.write. - GET /public/wishlists/insight
- Needs
wishlists.read. - GET /public/courses
- Needs
courses.read. - GET /public/courses/:courseId/students
- Needs
courses.read. - POST /public/courses/:courseId/students
- Needs
courses.write. - GET /public/courses/:courseId/students/:studentId
- Needs
courses.read. - POST /public/courses/:courseId/students/:studentId/revoke
- Needs
courses.write. - GET /public/licenses
- Needs
licenses.read. Issued licences, with the keys masked — revealing one is its own act behind its own step-up grant, and an API key does not carry that authority. - POST /public/licenses/lookup
- Needs
licenses.read. - GET /public/licenses/:licenseId/activations
- Needs
licenses.manage. - POST /public/licenses/:licenseId/suspend
- Needs
licenses.manage. - POST /public/licenses/:licenseId/resume
- Needs
licenses.manage. - POST /public/licenses/:licenseId/reset-activations
- Needs
licenses.manage. - POST /public/licenses/:licenseId/activations/:activationId/release
- Needs
licenses.manage.
The OpenAPI document
Everything above, machine-readable. Generated from the same Zod schemas the routes validate requests against, served unauthenticated, and checked against the live routes by a test that fails in both directions — a route with no entry, and an entry with no route.
Import it
curl https://<api-host>/api/v1/openapi.json -o sellstamp.json # Postman → Import → sellstamp.json # Insomnia → Import → sellstamp.json # n8n / Zapier / Make → custom connector from an OpenAPI file
Why 3.0 and not 3.1
3.1 is the better specification and 3.0 is the one every importer reads without complaint. This document exists to be imported, so it is the one that gets imported.
No separate Postman collection
A collection file is a second description of the same 50 endpoints, maintained by hand, which is precisely the thing this replaced. Postman imports OpenAPI directly.
Not a credential
The spec names no merchant and no row, so it needs no key — every tool that makes it worth generating fetches it before it has anywhere to put one.
The contract every endpoint keeps
- Errors
- RFC 7807
application/problem+json, every time: 401 for auth, 403 for scope, 404 for not-found and for anything belonging to another workspace, 409 for conflicts, 410 for expired grants, 422 for validation, 429 for rate limits. A cross-workspace 404 is deliberate — 403 would confirm the thing exists. - Pagination
- Keyset, never offset.
?limit=(100 max) and?cursor=, and the response carriesnextCursorandhasMore. Rows land in these tables while you are walking them, and an offset would silently skip one and repeat another; comparing against the last row’s sort key cannot. - Idempotency
Idempotency-Keyon the write, 1–255 characters. The key is claimed only once the request is well formed and aimed at a store yours can reach, so a typo never burns it.- Money
- Integer minor units and an ISO currency code, always both fields, never a decimal. Totals are never summed across currencies.
- Timestamps
- ISO-8601, UTC.
Rate limits, and knowing where you stand
Per key, not per address, and separate from the per-IP limits the rest of the API applies. Your plan sets the number: 120 requests a minute on Growth, 600 on Scale, 6,000 on Enterprise — and a key may carry a lower one of its own if you want to throttle a script you do not fully trust. It can never be set above the plan.
The headers, in one spelling
RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset on every response, using the IETF draft names. A 429 carries all three plus Retry-After, in seconds — back off on that number rather than guessing. The older X-RateLimit-* spelling is not emitted.
The first request is limited at 120
Whatever the key's tier. The limiter has to choose a ceiling before any lookup could say whose key this is, which is the right order — spraying invalid keys must be throttled before it costs a query. The real number is learned during authentication and used from the next request on.
And a usage meter
The dashboard charts your workspace's requests per day over thirty days, per key, with the templated routes each one actually calls — which identifies a script faster than its name does. A 403 counts: “which key keeps hitting a scope wall” is one of the questions the page exists to answer. No URL, query string, body or address is stored.
Getting a key
Settings → API keys, in the dashboard. Creating, editing and rotating each need a fresh proof of identity on top of your role — minting a machine credential is not something a step-up for a different purpose should silently authorise. Revoking is deliberately not gated: turning a credential off is the safe direction.
What this does not do
- No API keys on Trial or Starter. Growth allows five, Scale twenty-five, Enterprise as many as you need — and only live keys occupy a slot.
- No generated client in another language. The OpenAPI document above makes a Python or Go SDK a real option; codegen quality varies wildly by target, so which ones are worth publishing is its own decision rather than a thing to do because it became possible.
- No sandbox, no fixture data and no fake payment provider behind the mode flag. Building one is a parallel dataset and a fake provider, not a flag.
- No GraphQL, no bulk export endpoint, and no way to read a licence key — the reveal is a human act behind a step-up grant.
- No public endpoint for subscriptions or fulfilment. Those are dashboard routes today; webhooks are how an integration learns they happened.
Sellstamp