Integrating the TypeScript SDK

By the end of this page, you will know how to install the TypeScript SDK, create the client, read a product's history, manage your notification subscriptions, tell the two families of errors apart, and run a complete program.

On this page

The TypeScript SDK is a library published on npm under the name @sealtrust-io/sdk. It sends the partner API calls on your behalf and returns typed objects to you. By the end of this page, you will know how to install it, create the client, read a product's history, manage your notification subscriptions, tell the two families of errors apart, and run a complete program that declares a subscription then reads a timeline.

You will also know how to create products in batches through the API, and what the server decides on your behalf on that path.

You first need an API key. You create it from your brand's console, in Settings then Developers, with the scopes your calls need.

#What the SDK covers

The SDK exposes three families of methods, and nothing else. Every row of the table below has been checked against the route it calls.

MethodRoute calledScope required on the key
products.mint()POST /partner/mint/batchmint:batch
products.getBatchStatus()GET /partner/mint/batch/status/{job_id}mint:batch
webhooks.create()POST /partner/webhookswebhooks:write
webhooks.list()GET /partner/webhookswebhooks:read
webhooks.get()GET /partner/webhooks/{webhook_id}webhooks:read
webhooks.update()PUT /partner/webhooks/{webhook_id}webhooks:write
webhooks.delete()DELETE /partner/webhooks/{webhook_id}webhooks:write
verify.timeline()GET /timeline/{identifier}none, public route

Two further methods exist in the SDK and refuse an API key: verify.batch() and verify.metadataIntegrity(). The routes they call expect a console session. An API key comes back from them with 401. They are described further down.

One route of the partner API has no method in the SDK: the declaration of a sale to the end customer, POST /partner/sellout, which requires the sellout:write scope. Call it with fetch as long as the SDK does not carry it.

#Installing

The latest published version is 0.3.0. The package requires Node 18 or more recent, because it uses the language's native fetch.

Terminal
npm install @sealtrust-io/sdk
Terminal
yarn add @sealtrust-io/sdk

The package gives you two formats, ES modules and CommonJS, with its type definitions.

The examples on this page use setTimeout and process, which belong to Node. The package does not declare Node's types, so your compiler ignores them until you install them.

Terminal
npm install --save-dev @types/node

#Creating the client

You build the client once, with your key, and you reuse it for all your calls.

TypeScript
import { SealTrustClient } from "@sealtrust-io/sdk";

const sealtrust = new SealTrustClient({
  apiKey: process.env.SEALTRUST_API_KEY ?? "",
});

You set the SEALTRUST_API_KEY variable in your server's environment. The name is yours, the SDK reads no variable by itself. If the variable is absent, the construction of the client fails immediately, before any call.

The constructor accepts four options.

OptionTypeRequiredDescription
apiKeystringyesYour API key. It goes out in the Authorization: Bearer <clef> header. An empty value makes the construction of the client fail immediately.
baseUrlstringnoThe address of the API. Default value https://api.sealtrust.io.
timeoutnumbernoMaximum duration of a call, in milliseconds. Default value 30000.
fetchtypeof fetchnoThe implementation of fetch to use. Default value the one of the language.

The fetch option serves to test your own code without touching a global variable. You pass it a function that returns the response of your choice.

TypeScript
const clientDeTest = new SealTrustClient({
  apiKey: "clef-factice-du-test",
  fetch: async () =>
    new Response(JSON.stringify({ token_id: "1", timeline: [] }), {
      status: 200,
      headers: { "content-type": "application/json" },
    }),
});

Here the key does not matter: the fetch function you supply returns the response of your choice and sends the request nowhere. The constructor only requires that it not be empty.

#Reading a product's history

verify.timeline() returns the verifications and the ownership transfers of a product, in a single object.

Three forms of identifier are accepted.

  • The printed serial number, the one the QR code on the product carries. It is the only one a human can read on an object.
  • The token identifier, in base ten.
  • The UID hash, 0x followed by 64 hexadecimal characters.
TypeScript
const histoire = await sealtrust.verify.timeline("EXEMP1E00001");

console.log(histoire.product_name);
console.log(histoire.brand_name);
for (const evenement of histoire.timeline) {
  console.log(evenement.type, evenement.timestamp);
}

This route is public. Your API key is neither required nor read there, and the response is the same with or without it. It carries its own limit, 30 calls per 60 seconds and per calling address, independent of your key's quota. Beyond that, it answers 429. A well formed but unknown identifier answers 404.

The SDK refuses . and .. as identifiers and raises an error before any call. These two values are not valid identifiers, and left as they are they would send the request to an address other than the one requested.

#Two methods that refuse an API key

verify.batch() and verify.metadataIntegrity() call real routes. Those routes expect a console session. An API key fails when that session is read and the call comes back with 401. The code returned is the one of a refused authentication, which gives the impression of a broken key.

Do not use these two methods with the key of your server integration.

#Managing notification subscriptions

The five webhooks methods cover all of the partner API's subscription routes.

TypeScript
const abonnement = await sealtrust.webhooks.create({
  url: "https://exemple-sas.example/webhooks/sealtrust",
  events: ["product.minted", "product.transferred"],
  secret: "secret-de-demonstration-a-remplacer",
});

console.log(abonnement.id, abonnement.events, abonnement.health);

The address must start with https://. The events field is optional in the technical sense, and a subscription created without it never receives anything while displaying itself in good health. Always fill it in.

The secret field is yours. The API records it as it is and signs every delivery with it. If you do not send it, the deliveries go out unsigned.

The health field equals healthy as long as the deliveries succeed, and degraded when the series of retries gives up on your address. It goes back to healthy on the first successful delivery. It is the field to watch.

TypeScript
const page = await sealtrust.webhooks.list({ skip: 0, limit: 20 });

console.log(page.total);
for (const item of page.items) {
  console.log(item.id, item.url, item.event_types, item.health);
}

limit goes from 1 to 100, and equals 20 by default. The body of the response carries total and items, and nothing else: the skip and limit values you sent do not come back.

#Deleting a subscription requires its address

webhooks.delete() takes two arguments: the identifier, and the address of that same subscription. The API compares the address you send with the one it has recorded. If the two differ, nothing is deleted and the call fails with 400 and the code CONFIRMATION_MISMATCH. With no address at all, the code is CONFIRMATION_REQUIRED.

TypeScript
const cible = await sealtrust.webhooks.get(7);
await sealtrust.webhooks.delete(cible.id, cible.url);

Deleting a subscription also erases the recorded signing secret. You will have to send it again at the next creation, and until you have done so, the deliveries go out unsigned. There is no undoing this.

The SDK does not go and fetch the address on your behalf, deliberately. The check exists to catch an identifier that does not designate what you think, and a read made on that same identifier would confirm the wrong subscription just as well as the right one.

The error message never contains the expected address. Read it with webhooks.get(id).

#Sending a batch to the partner API

This endpoint creates products. The server itself sets two values on each row: the identification method, which equals qr, and the item's UID hash. Your row does not carry them and cannot carry them, the request schema refuses any field it does not know.

products.mint() accepts a single object or an array of objects. The SDK wraps the single object in an array before sending, so the result is identical.

A row accepts five fields, and five only.

FieldTypeRequiredDescription
product_namestringyesThe product's name.
brand_idnumberyesYour brand's number. It must match the brand of the key.
category_idnumberyesThe category's number.
metadata_uristringyesThe address of the product's metadata.
external_refstringnoYour own reference.

A batch counts 500 items at most. A single row carrying the number of another brand causes the whole batch to be refused with 403.

TypeScript
const lot = await sealtrust.products.mint([
  {
    product_name: "Sac Exemple 001",
    brand_id: 12,
    category_id: 3,
    metadata_uri: "ipfs://exemple-metadonnees-0001",
    external_ref: "EX-0001",
  },
]);

console.log(lot.job_id, lot.status, lot.items_count, lot.brand_id);

Four conditions come on top of the key's mint:batch scope, in this order.

  • Your brand's plan must include access to the API. Otherwise the response is 403 with the code FEATURE_NOT_AVAILABLE.
  • Your brand's plan must allow the qr method. Otherwise the whole batch is refused with 403 and the code AUTH_METHOD_NOT_ALLOWED.
  • Your brand's monthly product allowance must be able to absorb the whole batch. Otherwise the response is 403 with the code QUOTA_EXCEEDED, and the body gives current, additional, max and period.
  • The key's daily quota must remain sufficient. It counts one item per row. A batch of 200 items consumes 200 quota units. Otherwise the response is 429. The quota is consumed at the moment the batch is accepted, and it is not given back to you if the processing fails afterwards.

#The idempotency key of this route

POST /v1/partner/mint/batch is the only route of the partner API that reads the Idempotency-Key header. The SDK sets that header on every POST and every PUT, and all the other routes ignore it.

When you do not supply one, the SDK makes one up at random, which protects you from nothing: the SDK never retries by itself, one call equals one send, and a randomly drawn key changes at every send.

So that a new attempt after a network outage finds the first batch again instead of queuing a second one, supply your own key, and keep the same one between attempts. products.mint() takes it as a second argument.

TypeScript
const lot = await sealtrust.products.mint(
  [
    {
      product_name: "Sac Exemple 001",
      brand_id: 12,
      category_id: 3,
      metadata_uri: "ipfs://exemple-metadonnees-0001",
      external_ref: "EX-0001",
    },
  ],
  "exemple-lot-2026-08-20-001",
);

The API keeps your idempotency key for 24 hours. Replaying the same key with the same batch returns the response of the first call. Replaying the same key with a different batch returns 409.

#Following the progress of a batch

products.getBatchStatus() takes the identifier returned by products.mint().

The status field equals queued when waiting, started when in progress, finished when done, failed when failed, and unknown when the batch cannot be found. The type declares four other values that the execution queue can produce, which prevents an exhaustive switch from refusing to compile.

TypeScript
const etat = await sealtrust.products.getBatchStatus("9f2c4a7b1d3e5f60");
console.log(etat.status);

Loop as long as the status equals queued or started.

When the queue has forgotten a finished batch but the reservation still exists and belongs to you, the response carries four extra fields: batch_status, items_count, success_count and error_count. They are absent from all the other responses, which is why they are optional in the type.

#Telling the two families of errors apart

The SDK raises two error classes, and the difference calls for opposite reactions.

ClassWhat happenedWhat you have
SealTrustErrorThe API answered and refused.status, body, headers.
SealTrustNetworkErrorThe API could not be reached, or its response could not be read.cause.

SealTrustNetworkError covers three cases: transport failure, timeout exceeded, and a body announced as JSON that is not JSON, including an empty body on a 200 response.

TypeScript
import {
  SealTrustClient,
  SealTrustError,
  SealTrustNetworkError,
} from "@sealtrust-io/sdk";

const sealtrust = new SealTrustClient({
  apiKey: process.env.SEALTRUST_API_KEY ?? "",
});

try {
  await sealtrust.verify.timeline("EXEMP1E00001");
} catch (erreur) {
  if (erreur instanceof SealTrustError) {
    console.error(erreur.status, erreur.message);
    console.error(erreur.headers.get("x-request-id"));
  } else if (erreur instanceof SealTrustNetworkError) {
    console.error("API injoignable :", erreur.message);
  } else {
    throw erreur;
  }
}

The API's error body carries a detail field. It is a string in most refusals, an object when the route refuses in a structured way, and an array of fields when validation fails with 422. When it is an object, it carries a code field: FEATURE_NOT_AVAILABLE, AUTH_METHOD_NOT_ALLOWED, QUOTA_EXCEEDED, CONFIRMATION_MISMATCH. Base your logic on that code. The message, for its part, can change without notice.

TypeScript
import { SealTrustError } from "@sealtrust-io/sdk";

function codeDErreur(erreur: SealTrustError): string | null {
  const detail = erreur.body?.detail;
  if (detail && typeof detail === "object" && !Array.isArray(detail)) {
    const code = (detail as Record<string, unknown>).code;
    return typeof code === "string" ? code : null;
  }
  return null;
}

The request number travels in the X-Request-Id header, readable from erreur.headers.

#Two different refusals carry the code 429

A 429 does not mean the same thing depending on what produced it, and the header tells you which one you have.

RefusalHeadersWhen to retry
Rate limitRetry-After, plus X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and X-RateLimit-Scopeafter the number of seconds given by Retry-After
Daily quota of the keyX-Quota-Limit, X-Quota-Remaining, X-Quota-Reset. No Retry-Afterafter midnight UTC, when the quota starts again

The daily quota can refuse you on two routes only, the ones that consume items: products.mint() and the sale declaration. The rate limit, for its part, applies to every route that reads your key. X-Quota-Reset carries the timestamp of the last reset, it does not give the next one.

Retrying right away after a quota refusal fails the same way. The function below therefore waits in the first case, and returns false for the second.

TypeScript
import { SealTrustError } from "@sealtrust-io/sdk";

async function attendreSiPlafondDeDebit(erreur: unknown): Promise<boolean> {
  if (!(erreur instanceof SealTrustError) || erreur.status !== 429) {
    return false;
  }

  const retryAfter = erreur.headers.get("retry-after");
  if (retryAfter === null) {
    console.error(
      "Quota quotidien atteint :",
      erreur.headers.get("x-quota-remaining"),
      "sur",
      erreur.headers.get("x-quota-limit"),
      "Il repart à minuit UTC.",
    );
    return false;
  }

  const secondes = Number(retryAfter);
  await new Promise((resoudre) => setTimeout(resoudre, secondes * 1000));
  return true;
}

#Complete end-to-end example

This program does two things in a row: it declares a notification subscription if you do not have one on that address yet, then it reads a product's history. It compiles and runs as it is with Node 18 or more recent and @types/node installed, once the SEALTRUST_API_KEY variable is set in your environment, and the address as well as the product identifier replaced with yours.

Declaring a subscription requires a plan that includes notifications. If your plan does not include them, the call answers 403 FEATURE_NOT_AVAILABLE, the program says so and carries on.

TypeScript
import {
  SealTrustClient,
  SealTrustError,
  SealTrustNetworkError,
} from "@sealtrust-io/sdk";

const CLEF = process.env.SEALTRUST_API_KEY ?? "";
const ADRESSE_NOTIFICATION = "https://exemple-sas.example/webhooks/sealtrust";
const SECRET_NOTIFICATION = "secret-de-demonstration-a-remplacer";
const PRODUIT = "EXEMP1E00001";

const sealtrust = new SealTrustClient({ apiKey: CLEF });

function codeDErreur(erreur: SealTrustError): string | null {
  const detail = erreur.body?.detail;
  if (detail && typeof detail === "object" && !Array.isArray(detail)) {
    const code = (detail as Record<string, unknown>).code;
    return typeof code === "string" ? code : null;
  }
  return null;
}

async function chercherAbonnement(): Promise<number | null> {
  let vus = 0;

  for (;;) {
    const page = await sealtrust.webhooks.list({ skip: vus, limit: 100 });
    const trouve = page.items.find((item) => item.url === ADRESSE_NOTIFICATION);
    if (trouve) {
      return trouve.id;
    }

    vus += page.items.length;
    if (page.items.length === 0 || vus >= page.total) {
      return null;
    }
  }
}

async function declarerAbonnement(): Promise<number | null> {
  const existant = await chercherAbonnement();
  if (existant !== null) {
    console.log("Abonnement déjà présent :", existant);
    return existant;
  }

  try {
    const cree = await sealtrust.webhooks.create({
      url: ADRESSE_NOTIFICATION,
      events: ["product.minted", "product.transferred"],
      secret: SECRET_NOTIFICATION,
    });
    console.log("Abonnement créé :", cree.id, cree.events.join(", "));
    return cree.id;
  } catch (erreur) {
    if (
      erreur instanceof SealTrustError &&
      codeDErreur(erreur) === "FEATURE_NOT_AVAILABLE"
    ) {
      console.error("Offre sans notifications : abonnement non déclaré.");
      return null;
    }
    throw erreur;
  }
}

async function lireHistorique(): Promise<void> {
  const histoire = await sealtrust.verify.timeline(PRODUIT);

  console.log("Produit :", histoire.product_name);
  console.log("Marque :", histoire.brand_name);
  console.log("Événements :", histoire.timeline.length);

  for (const evenement of histoire.timeline) {
    console.log(" ", evenement.type, evenement.timestamp);
  }
}

async function principal(): Promise<void> {
  await declarerAbonnement();
  await lireHistorique();
}

principal().catch((erreur) => {
  if (erreur instanceof SealTrustError) {
    console.error(`L'API a refusé en ${erreur.status} :`, erreur.message);
    console.error("Numéro de requête :", erreur.headers.get("x-request-id"));
  } else if (erreur instanceof SealTrustNetworkError) {
    console.error("API injoignable :", erreur.message);
  } else {
    console.error(erreur);
  }
  process.exitCode = 1;
});

Reading the list before creating is deliberate. Without it, a program restarted after an outage declares a second subscription on the same address, and your receiving server gets every event twice.

#The known pitfalls, at a glance

What you seeCauseWhat to do
a finished batch without knowing how many items went throughthis status describes the execution queue, it does not count the itemsopen the list of products in the console, the SDK exposes no method that reads it
400 on a batch that the compiler acceptsyou are sending owner_email or contract_address, still declared in the type and refused by the APIremove those two fields
401 on verify.batch() or verify.metadataIntegrity()these routes expect a console sessiondo not use these two methods with an API key
403 with scopes named in the messagethe key does not have the scope required by the routecreate a new key carrying the named scope, from the console
403 on the whole batchat least one row carries a brand_id different from the one of the keycorrect the offending row, the whole batch is refused because of a single one
403 FEATURE_NOT_AVAILABLEyour plan does not include the function called: access to the API, or notifications on a creation and on a modification of a subscriptioncheck your plan in the console
403 AUTH_METHOD_NOT_ALLOWED on a batchyour plan does not allow the qr method, which the server sets on every row of this endpointcheck your plan in the console
403 QUOTA_EXCEEDEDyour brand's monthly product allowance is reached, and this batch would go past itreduce the size of the batch or wait for the next period; the body gives current, additional, max and period
products.list is not a function, or sealtrust.certificates equals undefinedyou are calling a method that is absent from the SDK: products.list(), products.get(), verify.product(), or the certificates familyno published version of the package has ever carried them, and the corresponding routes have never existed
undefined when reading hook.events on a list itemthe list returns event_typesread item.event_types on the items of list()
every event delivered twicetwo subscriptions carry the same address, a create() was replayedlist your subscriptions and delete the duplicate
a waiting loop that never stopsyou are testing is_finished, absent from the unknown responseloop on status, and stop as soon as it leaves queued and started
400 CONFIRMATION_MISMATCH on deletionthe address sent does not match the one recorded under this identifierread the subscription again with webhooks.get(id) and pass its url
429 with Retry-Afterthe rate limit is reachedwait the number of seconds given by Retry-After
429 without Retry-Afterthe key's daily quota is reachedwait for midnight UTC; retrying before then fails the same way
503the rate limiting service is momentarily unavailableretry shortly after, nothing has been processed

Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.

Suggest a correctionReport a problem