@sealtrust-io/sdk
v0.3.0
Published
Official TypeScript SDK for the SealTrust API
Maintainers
Readme
@sealtrust-io/sdk
Official TypeScript SDK for the SealTrust Partner API.
Mint products in batches, manage webhook subscriptions, and read a product's verification history.
Installation
npm install @sealtrust-io/sdk
# or
yarn add @sealtrust-io/sdkQuick start
import { SealTrustClient } from "@sealtrust-io/sdk";
const sealtrust = new SealTrustClient({
apiKey: "st_live_...", // or st_test_...
baseUrl: "https://api.sealtrust.io", // optional, this is the default
});baseUrl is a host, not a host and a path. Every method sends a /v1/… path
of its own, so a base url with a path on it would have that path replaced, not
extended.
Why /v1
The API mounts each business router twice: once under /v1 and once at the
root (_register_routers, backend/fastapi/app/main.py). Both reach the same
handler, and the unversioned copies are permanent aliases — our own
published apps rely on them. This SDK sends the /v1 paths, which is the form
a new integration should use.
Products
The Partner API's product surface is batch minting. There is no product list
and no product read: no router serves /partner/products, so the list() and
get() this SDK used to ship called nothing and returned 404. They are gone.
// Mint a single product
const job = await sealtrust.products.mint({
product_name: "Limited Edition Sneaker",
brand_id: 1, // must be your API key's own brand
category_id: 3,
metadata_uri: "https://metadata.sealtrust.io/abc123.json",
});
console.log(job.job_id); // poll with getBatchStatus()
// Mint a batch, up to 500 items per call
const batchJob = await sealtrust.products.mint([
{
product_name: "Sneaker #001",
brand_id: 1,
category_id: 3,
metadata_uri: "https://metadata.sealtrust.io/001.json",
},
{
product_name: "Sneaker #002",
brand_id: 1,
category_id: 3,
metadata_uri: "https://metadata.sealtrust.io/002.json",
external_ref: "SKU-002",
owner_email: "[email protected]",
},
]);
// Check batch status
const status = await sealtrust.products.getBatchStatus(batchJob.job_id);
console.log(status.status); // "queued" | "started" | "finished" | "failed" | "unknown"owner_email and contract_address are accepted by the request schema but the
batch worker reads neither: the minted product records the API key as its owner
and always lands on the active contract. external_ref is carried through.
Requires the mint:batch scope on the key and the api_access feature on the
brand's plan. Every item's brand_id must equal the key's brand: one mismatch
fails the whole batch with 403. The item schema forbids unknown keys, so an
extra field is a 400 naming the row rather than a silent drop.
status: "unknown" means the queue does not know that job id. That response
carries job_id and status and nothing else, so an absent is_finished is
"not found", not "not finished yet".
Verification
// Combined history: verifications + ownership transfers.
// The printed serial is the identifier you actually have: it is the value
// carried by the QR code on the product, and the only one a human can read
// off an object.
const timeline = await sealtrust.verify.timeline("Y5T2VGGF2NP9");
// A token id and a 0x uid hash resolve to the same product.
await sealtrust.verify.timeline("66515627126307070141059899368918743430");
await sealtrust.verify.timeline("0xabc123...def");
console.log(timeline.product_name);
console.log(timeline.timeline); // Array of eventstimeline() accepts three identifiers: the printed serial, the on-chain
token id, and the 0x uid hash. The parameter used to be named uidHash and
documented as taking only the hash, which sent integrators looking for a value
no product in their hand carries.
timeline() is a public route. Its user dependency is
get_current_user_optional, which returns null for anything it cannot decode
as a session token, so your API key is neither required nor read: you get the
anonymous projection either way. It is rate-limited per calling IP at 30
requests per 60 seconds, independently of your key's quota, and answers 429
past that.
verify.product() has been removed. It called POST /verify, which no router
serves. POST /sdm/verify is the real single-product verification, but it
takes a tag's plaintext UID, read counter and 8-byte CMAC and answers a full
timeline: a different request and a different response, so nothing was
repointed at it.
batch() and metadataIntegrity() do not accept an API key
Both routes exist, and both authenticate with get_current_user, which decodes
the bearer token as a session JWT. An st_live_ / st_test_ key is not a
JWT: it fails to decode and the call comes back 401 "Token JWT invalide". Not
a 403, and not a scope error, which is why it reads like a broken key rather
than the wrong kind of credential.
They are kept, because the routes are real and work. To call them, build a
client whose apiKey is a session access token:
const asUser = new SealTrustClient({ apiKey: sessionAccessToken });
const batch = await asUser.verify.batch(["0xabc123...def", "0x456789...012"]);
batch.results.forEach((item) => {
console.log(`${item.uid}: ${item.valid ? "authentic" : "invalid"}`);
});
const integrity = await asUser.verify.metadataIntegrity("0xabc123...def");
console.log(integrity.valid);Whether the Partner API should expose these under an API key is to be confirmed with the API team.
Certificates
There is no certificates resource. certificates.get() and
certificates.issue() called /partner/certificates*, which no router serves.
The one certificate route that exists is public and unauthenticated:
GET /v1/certificate/{identifier}identifier is a uid_hash, a token_id or a certificate number, not an
integer id, and the body is certificate_number, status, issued_at,
expires_at, issuer_name, product_name, brand_name, the brand's display
fields and custom_fields. It shares no field with the Certificate type this
SDK used to declare, so the resource was deleted rather than repointed. Issuing
a certificate over the Partner API is to be confirmed with the API team.
Webhooks
// List webhooks
const hooks = await sealtrust.webhooks.list();
// Create a webhook
const webhook = await sealtrust.webhooks.create({
url: "https://example.com/webhooks/sealtrust", // https:// is enforced
events: ["product.minted", "product.transferred"],
secret: "whsec_...",
});
// Update a webhook
await sealtrust.webhooks.update(webhook.id, {
events: ["product.minted", "product.transferred", "certificate.issued"],
});
// Delete a webhook: the id AND the subscription's own url
await sealtrust.webhooks.delete(webhook.id, webhook.url);Reads need the webhooks:read scope, writes need webhooks:write, and
creating also needs the webhooks feature on the brand's plan (a 403, not a
422).
list() items are a different shape from get(): the field is event_types
there and events here. That divergence is in the API and is to be confirmed
with the API team.
Why deleting a webhook needs the url as well as the id
webhooks.delete() takes two arguments, and the second one is the url of the
very subscription you are deleting. The API compares it against the url it has
stored under that id, and refuses with 400 if the two disagree.
It is not asking you to repeat yourself for the sake of it. An id is a small integer, so the mistakes it invites are ordinary ones: an id off by one, an id copied over from a staging account, an id read from a list that has since changed order. Deleting a webhook subscription destroys the shared secret every payload sent to that endpoint was signed with. If you create the subscription again you get a different secret, so the receiving system has to be reconfigured before it can verify anything again, and until somebody does that the integration is quietly dead. There is no undo.
Sending the url means the delete can only ever land on the subscription you actually looked at.
The SDK deliberately does not fetch the url for you. It could, in one extra request. It would also defeat the check entirely: the point is to catch an id that does not point where you think it points, and an SDK reading the url off that same id would confirm the wrong subscription exactly as readily as the right one. So the url comes from you:
// You already hold the subscription
const hook = await sealtrust.webhooks.get(7);
await sealtrust.webhooks.delete(hook.id, hook.url);
// You only stored an id: make the read explicit
const target = await sealtrust.webhooks.get(storedId);
await sealtrust.webhooks.delete(target.id, target.url);If the confirmation is missing or wrong, nothing is deleted and the call throws
a SealTrustError with status 400. err.body.detail has the shape of
ConfirmationErrorDetail:
import { SealTrustError, type ConfirmationErrorDetail } from "@sealtrust-io/sdk";
try {
await sealtrust.webhooks.delete(7, "https://wrong.example.com/hooks");
} catch (err) {
if (err instanceof SealTrustError && err.status === 400) {
const detail = err.body?.detail as ConfirmationErrorDetail;
// "CONFIRMATION_REQUIRED" (nothing sent) | "CONFIRMATION_MISMATCH" (wrong value)
console.error(detail.code, detail.message);
}
}The error never contains the url it expected. If it did, the refusal itself
could be pasted straight into a retry, which is precisely the mistake being
guarded against. Read the value from webhooks.get(id).
Under the hood the confirmation travels as the confirm query parameter on
DELETE /v1/partner/webhooks/{id}, url-encoded by the SDK.
Supported webhook event types
| Event | Description |
|-------|-------------|
| product.minted | A product NFT has been minted on-chain |
| product.transferred | Product ownership has been transferred |
| product.burned | A product NFT has been burned |
| product.status_changed | Product status has changed |
| batch.completed | A batch mint job completed successfully |
| batch.failed | A batch mint job failed |
| certificate.issued | A certificate of authenticity was issued |
| product.scanned | A product tag was scanned |
| product.gray_market | A scan was flagged as gray-market distribution |
| clone.alert | A possible tag clone was detected |
| transfer.accepted | A pending ownership transfer was accepted |
| return.requested / return.received / return.completed / return.rejected / return.expired | Return lifecycle |
| warranty.claimed / warranty.expiring_soon | Warranty lifecycle |
| buyback.offered / buyback.accepted / buyback.declined / buyback.completed / buyback.expired | Buyback lifecycle |
The list mirrors ALLOWED_EVENT_TYPES in the API. Anything outside it is
rejected at subscription time.
Not covered by this SDK
POST /v1/partner/sellout (app/routes/partner/sellout.py:40) is a real
Partner API route, authenticated by the same API key and gated on the
sellout:write scope. It declares a sell-out (a product handed to the final
customer) from {identifier, retailer_code, country?, city?}. This SDK has no
method for it; call it with fetch until it does.
Error handling
import { SealTrustClient, SealTrustError } from "@sealtrust-io/sdk";
try {
const status = await sealtrust.products.getBatchStatus("no-such-job");
} catch (err) {
if (err instanceof SealTrustError) {
console.error(`API error ${err.status}: ${err.message}`);
console.error("Response body:", err.body);
} else {
throw err;
}
}The API's error body is {"detail": …}. detail is a string on most
HTTPExceptions, an object on the routes that raise a structured refusal
(webhook delete confirmations), and an array of field errors on a 422. There
is no code and no request_id in the body. The request id is a response
header, X-Request-Id, set on every response by RequestLoggingMiddleware;
read it from err.headers.get("x-request-id").
Idempotency
POST and PUT requests carry an Idempotency-Key header, generated if you
do not supply one:
const job = await sealtrust.products.mint(
{ product_name: "Sneaker", brand_id: 1, category_id: 3, metadata_uri: "..." },
"my-custom-idempotency-key",
);POST /v1/partner/mint/batch is the only route that reads it. A replay of a
key that already succeeded returns the first response instead of enqueuing a
second job; a key whose first call is still in flight gets 409.
Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| apiKey | string | required | Your SealTrust API key (st_live_ / st_test_) |
| baseUrl | string | https://api.sealtrust.io | API host, no path |
| timeout | number | 30000 | Request timeout in milliseconds |
Errors
Two error types, and the difference matters:
import { SealTrustClient, SealTrustError, SealTrustNetworkError } from "@sealtrust-io/sdk";
try {
await sealtrust.verify.timeline("Y5T2VGGF2NP9");
} catch (err) {
if (err instanceof SealTrustError) {
// The API answered, and it said no. `err.status`, `err.body`, `err.headers`.
} else if (err instanceof SealTrustNetworkError) {
// The API could not be reached, or its answer could not be read:
// transport failure, timeout, or a body that claimed to be JSON and was not.
}
}"The certificate is revoked" and "your server has no network" call for opposite
reactions, which is why they are different types. Before 0.3.0 the second
escaped as a raw TypeError or SyntaxError that no catch (e) { if (e
instanceof SealTrustError) } would ever see.
Testing your own code
Pass your own fetch. Nothing else is needed, and no global is touched:
const sealtrust = new SealTrustClient({
apiKey: "st_test_...",
fetch: async () =>
new Response(JSON.stringify({ token_id: "1", timeline: [] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
});Requirements
- Node.js >= 18 (uses native
fetch) - TypeScript >= 5.0 (for type definitions)
Design notes for this first release
0.2.0 is the first version of this package published to npm. There is no
0.1.0 and never was: the registry answered 404 on this name until now, and no
earlier version existed anywhere but in our own repository.
So nothing below breaks anyone's code. These are the decisions taken while building the initial surface, kept here because they explain why some obvious methods are absent and why two signatures look the way they do. If you are starting from scratch, you can skip to Requirements; if you wrote against an internal build of this package, this is your migration.
Five methods are absent, because the routes they called do not exist.
| Removed | Called | Why |
|---------|--------|-----|
| products.list() | GET /partner/products | No router has that prefix. The four under app/routes/partner/ are /partner/mint, /partner/webhooks, /partner/sellout and /partner-portal. |
| products.get(id) | GET /partner/products/{id} | Same. |
| certificates.get(id) | GET /partner/certificates/{id} | No /partner/certificates router. The public GET /certificate/{identifier} shares no field with the type this returned. |
| certificates.issue(…) | POST /partner/certificates | Same. Nothing issues a certificate over the Partner API. |
| verify.product(uidHash) | POST /verify | app/routes/verify.py registers nine paths and none is a bare /verify. |
The whole certificates namespace goes with them: sealtrust.certificates is
now undefined.
Every one of them failed at runtime with a 404, so no working call site can
exist. They are listed rather than silently dropped: an SDK that quietly lacks
a method looks incomplete, whereas one that says which routes do not exist is
telling you something about the API.
Every request path is versioned under /v1.
The unversioned copies of these routes are also served, and they are permanent:
they answered with a Sunset header for a while, an announcement that was
withdrawn because our own installed apps call them. This SDK only ever sends the
versioned path. It matters to you only if you match on paths in a proxy or in
test fixtures.
The idempotency header is Idempotency-Key, not X-Idempotency-Key.
POST /partner/mint/batch declares Header(None, alias="Idempotency-Key").
The X- prefixed name matched nothing, so idempotency_key was always None:
the response cache was never consulted and the in-flight lock was never taken.
A retried batch mint enqueued a second job and minted the batch twice. Passing
your own key already worked at the call site; it now reaches the API.
webhooks.delete(id, confirmUrl) takes two arguments, on purpose.
The API does not accept a delete that carries only an id: it answers
400 CONFIRMATION_REQUIRED, whatever client sends it. See Why deleting a webhook needs the url as well as the
id.
await sealtrust.webhooks.delete(hook.id, hook.url);If your call site only has an id, fetch the subscription first and pass its url. The extra argument is required by the type signature on purpose: the failure shows up when you compile, not when a delete runs in production.
WebhookSubscription carries health, and no failure counters.
api_key_id, last_triggered_at, last_status_code and
consecutive_failures were once declared on the API response schema but existed
on no column of the subscription row. Pydantic's from_attributes fell back to the
declared defaults, so every response carried null, null, null and 0
forever, whatever the subscription had actually done. The API removed them, and
so has this SDK: a field that cannot vary is worse than an absent one, because
alerting gets built on it.
health, in their place, is a real column: "healthy" while deliveries succeed,
"degraded" once the retry schedule gives up on the endpoint, back to
"healthy" on the next success. It is what the admin surface already uses to
say whether an endpoint is answering, and it is the field to alert on.
if (hook.health === "degraded") alert(hook.url);There is no equivalent of last_triggered_at or last_status_code today. Per-delivery history lives in the delivery log,
which the Partner API does not surface yet.
WebhookListResponse now matches what the list endpoint really sends.
count,skipandlimitare removed. The route computes them, but the declared response model carries onlytotalanditems, so FastAPI drops them before they reach the wire. Reading them gave youundefinedat runtime while type-checking cleanly. Page with theskip/limityou sent.itemsis now typedWebhookSubscriptionListItem, notWebhookSubscription. The list endpoint serialises subscriptions with a different schema fromget(): the field isevent_typesthere andeventshere. That inconsistency is in the API, is to be confirmed with the API team, and is described rather than hidden, because hiding it would mean the SDK reporting aneventsarray that never arrives.
const page = await sealtrust.webhooks.list();
- for (const hook of page.items) console.log(hook.events);
+ for (const hook of page.items) console.log(hook.event_types);TimelineResponse gained three fields and made two nullable.
category_id, image_url and current_owner_is_vault are on
CombinedTimelineResponse and were missing here. product_name and brand_id
are optional on that schema and were typed non-nullable, so a product with
neither type-checked as a string and a number and arrived as null.
TimelineEntry gained event_id for the same reason.
Also in 0.2.0:
descriptionremoved fromWebhookCreateRequest,WebhookUpdateRequestandWebhookSubscription. The subscription row has no such column and the API request schemas forbid unknown keys, so sending it was a422and it was never returned.WebhookEventTypewidened to the full set the API accepts: the return, warranty and buyback lifecycle events were missing, so subscribing to them failed to type-check even though the API allowed them.Product,PaginatedResponse,PaginationParams,Certificate,CertificateIssueRequestandVerificationResponseare no longer exported. Each existed only for one of the five removed methods.
License
Proprietary. All rights reserved.
