Skip to contentSellstamp

SDKs

A licence check in six languages, and a REST client in one.

Checking a licence means an Ed25519 signature over a canonical payload, in an exact order, with a clock you cannot trust — so it exists in TypeScript, PHP, Python, Go, Ruby and Java, and every one of them passes the same signed fixtures. Calling the REST API means opaque cursors, scope refusals, rate-limit budgets and one write you must never retry blindly; that client, and the CLI built on it, are TypeScript only. Everything else is an HTTP call your language already makes well, and this page is honest about what it is not.

@sellstamp/license-verify

import { verifyLicense } from "@sellstamp/license-verify";

const check = verifyLicense(response, PUBLIC_KEY, {
  clock: myClockStore,
});

if (!check.ok) {
  // "tampered" | "expired" | "clock_rollback" | …
  refuse(check.reason);
}

MIT. Node 22+. No runtime dependencies.

What the verifier actually does

In Node it is not a second implementation: the signature check and the offline-file envelope are the platform's own code, re-exported, so there is no second canonicaliser to drift out of agreement with the server. What the package adds is the decision around them — and every port below makes the same decision, in the same order.

Verify a live response
Hand it the JSON your activation or validation call returned and the public key you embedded at build time. You get { ok: true, response } or a reason.
Write and read offline activation files
buildOfflineRequest writes the small text file an air-gapped machine emits; verifyOfflineResponse reads what comes back — which is exactly the same signed validation response, through the same verifier, so an offline install is not a second code path in your app.
Embed the key, do not fetch it
The platform publishes its verification key at GET /api/v1/licenses/public-key. Put the PEM in your source at build time — anyone who can forge a response can serve you their own key, so fetching it at runtime and trusting whatever arrives defeats the whole mechanism.
A fixed order, on purpose
Signature first, so no later step ever reads an unverified field. Clock before expiry, so a rolled-back clock is never reported as “still valid”.

Six languages, one set of fixtures

The other five are ports of the Node package, not reinterpretations of it: each is run against the same signed responses and has to reach the same verdict, down to the failure reason. Only the Node package is published so far — the rest are built and tested, and say so below instead of printing an install command that would fail for you.

TypeScript / Node
@sellstamp/license-verify, on npm. Node 22+, and node:crypto does the Ed25519 — no runtime dependencies.
npm install @sellstamp/license-verify

const check = verifyLicense(await res.json(), PUBLIC_KEY);
PHP
sellstamp/license-verify. PHP 7.4+ with the libsodium extension PHP has bundled since 7.2, and nothing else. Not on Packagist yet. It is one file, written so a WordPress plugin can copy it in and require_once it.
use function Sellstamp\LicenseVerify\verifyLicense;

$check = verifyLicense(json_decode($body, true), PUBLIC_KEY);
Python
sellstamp-license-verify. Python 3.9+ and PyNaCl — the one port that needs a package on every runtime, because Python’s standard library has no Ed25519. Not on PyPI yet.
from sellstamp_license_verify import verify_license

check = verify_license(res.json(), PUBLIC_KEY)
Go
Package licenseverify. Go 1.24+, standard library only — crypto/ed25519. The module path is a placeholder until it is published and does not resolve, so there is no go get yet; it is one file, meant to be vendored into a single binary.
check, err := licenseverify.VerifyLicense(res, publicKey, licenseverify.VerifyOptions{})
Ruby
sellstamp-license-verify. Ruby 3.1+, standard library only — openssl and json. Not on RubyGems yet.
require "sellstamp/license_verify"

check = Sellstamp::LicenseVerify.verify_license(JSON.parse(body), PUBLIC_KEY)
Java
com.sellstamp:license-verify. Java 8+, and on Java 15+ the JDK alone verifies Ed25519. On 8 and 11 add BouncyCastle — the SDK finds it on the classpath and uses it only when the JDK cannot. Not on Maven Central yet.
LicenseCheck check = LicenseVerify.verifyLicense(Response.parse(body), PUBLIC_KEY);

Failure is a reason, not a boolean

“Invalid” is not an answer a support agent can act on. Eight outcomes, each with a different fix, and the same eight names in every language above.

unsigned
No signature field — an unsigned plan, or a response that had it stripped.
wrong_key
The keyId is not the key you embedded. Derived before verification, because its fix is different: ship a build with the new key, rather than stop running.
tampered
The signature does not verify. A field was edited, or the signature was.
malformed_date
It verified, but expiresAt or issuedAt is not a timestamp the server writes. The file was corrupted or hand-edited, and a date nobody can read is never taken to mean “no expiry”.
rejected
It verified, and the server said no. The code says why — suspended, revoked, expired, not yet active, activation limit reached, no seat free, or a key it did not recognise.
expired
Verified, and expiresAt is behind now.
offline_window_expired
Verified, but issuedAt + offlineGraceDays is behind now. Your software has been away from the network longer than the merchant allowed.
clock_rollback
The local clock is earlier than a moment this SDK has already seen.

The clock, and why it only goes forward

An offline grace window is a promise about elapsed time, and the machine measuring it belongs to the customer. Winding the clock back is the obvious attack, and the obvious defence — “invalidate if the clock goes behind issuedAt” — has a hole: with a 30-day window, winding day 29 back to day 1 never passes issuedAt, and can be repeated forever.

  • A high-water mark of both

    The store holds the greatest instant the SDK has ever seen — the signed issuedAt and the local clock, at every check. A replayed old response cannot pull it back, and neither can a text editor.

  • The ceiling, named

    A clock set forward and then honestly corrected trips it too, until real time catches up. That is accepted rather than hidden: a forward clock buys no offline time, and clearing the store after a fresh online validation is the recovery — which only your app knows it just did.

  • Storage is yours

    ClockStore is two synchronous methods, get and set. A desktop app has a settings file, an Electron app has a keychain; the package does not choose for you and ships no filesystem dependency into your customer's tree.

  • Skew is a knob

    Five minutes by default. Real clocks drift, a signed timestamp a few seconds ahead of the local one is not tampering, and flagging it would lock out honest customers on their very first check.

@sellstamp/sdk — the REST client

Typed, hand-written against the routes rather than generated from a spec there isn’t, and built so the three things that bite an integration on day 90 — paging, scopes and retries — are decided in the package instead of in your try/catch.

@sellstamp/sdk

import { SellstampClient } from "@sellstamp/sdk";

const client = new SellstampClient({ token: process.env.SELLSTAMP_API_KEY! });

for await (const order of client.orders.all()) {
  console.log(order.id, order.totalCents);
}

await client.orders.refund(order.id, { reason: "Duplicate" }, {
  idempotencyKey: crypto.randomUUID(),
});
One method per endpoint
orders, products, customers, coupons and licenses — not a generic request(method, path, body) with types bolted on the outside. If an endpoint is not there, it is because the API does not have it.
One token, either credential
An API key and an installed app's OAuth access token authenticate identically against these routes, so the client takes one token and never asks which kind it is holding.
A refusal is a class, not a status code
A missing scope throws SellstampScopeError carrying requiredScope; a throttle throws SellstampRateLimitError carrying retryAfterSeconds. Nobody has to match on a string in a message that may be reworded.
The cursor is walked for you
all() and pages() follow hasMore to the end — never rows.length < limit, which is the bug every hand-rolled pager ships with.
Retries are off until you ask
And even then a mutation is retried only if you passed an Idempotency-Key. A refund retried after a timeout without one is a second refund, and a client that did it for you would be worse than no client.
Where you stand in the budget
client.rateLimit holds the RateLimit-* headers from the last response, so “am I about to be throttled” is a property and not a header parse.

@sellstamp/cli — the same client, in a terminal

Built on the SDK rather than beside it, so there is one HTTP layer to keep honest. JSON on stdout by default, because the second reader of a developer tool is usually another program.

@sellstamp/cli

sellstamp login --api-key sellstamp_live_…   # ~/.sellstamp/config.json, 0600

sellstamp orders list --all | jq -r 'select(.status == "paid") | .id'
sellstamp products create --shop "$SHOP" --data '{"name":"Widget","priceCents":2500}'

sellstamp licenses list > /dev/null
[ $? -eq 3 ] && echo "this key is missing the licenses.read scope"
Pipeable by default
JSON on stdout, refusals as JSON on stderr, and --pretty for the human table — never the other way round. A tool whose default output is for a person is a tool you cannot put in a pipeline.
One exit code per refusal
3 is a missing scope, 4 a 404, 5 a throttle, 1 a local mistake that never reached the API. A script branches on which refusal it got, not on “nonzero happened”.
Writes take the API's own JSON
--data is the documented request body — from a string, a file or stdin — so no flag name here can drift from the API reference. Every write carries an Idempotency-Key, and --idempotency-key reuses one across a retry loop.
The cursor, or none of it
--limit and --cursor are the API's own and stay opaque; --all walks every page through the SDK's generator, so an export never stops one page short and calls it the whole dataset.
The key is a credential at rest
0600 in a 0700 directory, and `--api-key -` reads it from stdin so a live key never lands in your shell history. SELLSTAMP_API_KEY in the environment overrides the file, which is what CI should use.

The integration guide, for any language

The written guide is the other half of this, and it is not a template you edit. Open a product with licensing on and the dashboard generates an integration guide from that product's real policy — its binding mode, its activation limit, its validity rule, its lease timing, its entitlements — and states the HTTP contract in language-agnostic terms.

  • Built for your coding assistant

    It describes the contract; the assistant reading it already knows what language your codebase is in. A Node project gets pointed at the package first, then the same contract underneath it.

  • It contains nothing secret

    The activation API is unauthenticated by design — the licence key is the credential — and the verification key is published. The generator is handed policy facts only and can never be handed the platform's own keys.

  • It matches the product you are selling

    A domain-locked plugin and a floating-seat render farm need different code, and the guide for each says so, rather than describing a generic licence and leaving you to work out which half applies.

And a remote MCP server

Not an SDK, but the other way code reaches a store: a merchant can connect Claude, ChatGPT or Gemini to one of their stores over OAuth. It is listed here because a developer evaluating the API should know it exists and how tightly it is bounded.

Five tools, four permissions
List and read orders, list products, list licences with keys masked, and create a product. The merchant ticks which of those a connection gets.
One store, and no argument for another
A connection is tied to the single store approved for it. There is no field in any tool for naming a store — not reachable by asking, by naming one, or by any argument.
A product an AI creates is a draft
It does not appear on the storefront and nobody can buy it until a person publishes it.
Twenty changes an hour, fixed
A ceiling on the connection that nothing the AI does can raise — not even if the merchant approves every prompt it shows them.
What is simply absent
No tool changes a price, refunds an order, deletes anything or reveals a licence key. Revealing a key needs a re-authenticated human session, and no connection carries that authority.

Limits worth knowing before you build on it

What this does not do

  • No REST client for any language but TypeScript. A Python or Go client is its own packaging, its own release cadence and its own drift; every other language calls the API directly, against the contract in the docs.
  • No browser login for the CLI. `sellstamp login --api-key` pastes a key into a 0600 dotfile; a device-code grant is a new OAuth grant type with its own polling, expiry and security review, and the CLI is usable on day one without it.
  • Only one package on a registry. @sellstamp/license-verify is on npm; @sellstamp/sdk, @sellstamp/cli and the PHP, Python, Go, Ruby and Java verifiers are built and tested but not published, so nothing on this page gives you an install command for them.
  • No licence verifier outside those six languages. Any other language integrates against the generated guide and the published key.
  • No webhook-verification helper. It is one HMAC-SHA256 over the raw body — a package for that would be a dependency you audit to save a line.
  • No browser build of the licence verifier. Verifying a licence in code the customer can edit proves nothing; it belongs on a machine you control or inside your own binary.

Open a store and try it.

Everything on this page is in the product today. Connect a provider in test mode and put a real order through it before you decide.