npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sealtrust-io/sdk

v0.3.0

Published

Official TypeScript SDK for the SealTrust API

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/sdk

Quick 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 events

timeline() 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, skip and limit are removed. The route computes them, but the declared response model carries only total and items, so FastAPI drops them before they reach the wire. Reading them gave you undefined at runtime while type-checking cleanly. Page with the skip/limit you sent.
  • items is now typed WebhookSubscriptionListItem, not WebhookSubscription. The list endpoint serialises subscriptions with a different schema from get(): the field is event_types there and events here. 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 an events array 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:

  • description removed from WebhookCreateRequest, WebhookUpdateRequest and WebhookSubscription. The subscription row has no such column and the API request schemas forbid unknown keys, so sending it was a 422 and it was never returned.
  • WebhookEventType widened 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, CertificateIssueRequest and VerificationResponse are no longer exported. Each existed only for one of the five removed methods.

License

Proprietary. All rights reserved.