# 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.

Source: https://docs.sealtrust.io/en/sdk-typescript/

---

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.

> [!DANGER] Your API key opens access to your products
> Keep it on your server. Do not put it in a code repository, in a browser page,
> or in a mobile application: anyone who reads it calls the API on your behalf.
> Read it from an environment variable, as every example on this page does.

## 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.

| Method | Route called | Scope required on the key |
| --- | --- | --- |
| `products.mint()` | [`POST /partner/mint/batch`](/en/reference/post-partner-mint-batch/) | `mint:batch` |
| `products.getBatchStatus()` | [`GET /partner/mint/batch/status/{job_id}`](/en/reference/get-partner-mint-batch-status/) | `mint:batch` |
| `webhooks.create()` | [`POST /partner/webhooks`](/en/reference/post-partner-webhooks/) | `webhooks:write` |
| `webhooks.list()` | [`GET /partner/webhooks`](/en/reference/get-partner-webhooks/) | `webhooks:read` |
| `webhooks.get()` | [`GET /partner/webhooks/{webhook_id}`](/en/reference/get-partner-webhooks-id/) | `webhooks:read` |
| `webhooks.update()` | [`PUT /partner/webhooks/{webhook_id}`](/en/reference/put-partner-webhooks-id/) | `webhooks:write` |
| `webhooks.delete()` | [`DELETE /partner/webhooks/{webhook_id}`](/en/reference/delete-partner-webhooks-id/) | `webhooks:write` |
| `verify.timeline()` | [`GET /timeline/{identifier}`](/en/reference/get-timeline/) | 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`](/en/reference/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`.

```bash
npm install @sealtrust-io/sdk
```

```bash
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.

```bash
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.

| Option | Type | Required | Description |
| --- | --- | --- | --- |
| `apiKey` | `string` | yes | Your API key. It goes out in the `Authorization: Bearer <clef>` header. An empty value makes the construction of the client fail immediately. |
| `baseUrl` | `string` | no | The address of the API. Default value `https://api.sealtrust.io`. |
| `timeout` | `number` | no | Maximum duration of a call, in milliseconds. Default value 30000. |
| `fetch` | `typeof fetch` | no | The implementation of `fetch` to use. Default value the one of the language. |

> [!ATTENTION] `baseUrl` takes a host, without a path
> Every method already sends a path that starts with `/v1/`. If you fill in
> `baseUrl` with a path, that path is replaced instead of being appended. Write
> `https://api.sealtrust.io` and nothing more.

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.

> [!ATTENTION] Creating and modifying require a plan that includes notifications
> `webhooks.create()` and `webhooks.update()` answer 403 with the code
> `FEATURE_NOT_AVAILABLE` when your brand's plan does not include notifications.
> The entry-level plan that opens access to the API is in that case. One single
> modification escapes the rule, switching off alone, that is to say a body that
> carries only `{ is_active: false }`. Reading, listing and deleting stay open on
> every plan. On a plan without notifications, sending is closed too: a
> subscription already declared receives nothing more.

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.

> [!DANGER] A replayed `webhooks.create()` creates a second subscription
> This route reads no idempotency key. Two identical calls give two
> subscriptions on the same address, and your receiving server then gets every
> event twice. Nothing warns you. Read the list again with `webhooks.list()`
> before creating, and create only if the address is absent.

> [!ATTENTION] The list and the single read do not return the same shape
> `webhooks.get()`, `webhooks.create()` and `webhooks.update()` return the field
> under the name `events`. The items of `webhooks.list()` return it under the
> name `event_types`. That gap comes from the API. The SDK leaves it visible. If
> it hid it, you would be reading an array that never arrives.

```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.

> [!ATTENTION] This path creates items identified by QR code
> A machine call has no NFC chip at hand, and nothing here makes it possible to
> attach a chip's identifier to an item. NFC items are created from the console,
> at the moment the chip is encoded. QR is an identification mode in its own
> right.

`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.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `product_name` | `string` | yes | The product's name. |
| `brand_id` | `number` | yes | Your brand's number. It must match the brand of the key. |
| `category_id` | `number` | yes | The category's number. |
| `metadata_uri` | `string` | yes | The address of the product's metadata. |
| `external_ref` | `string` | no | Your 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.

> [!DANGER] The type allows two fields that the API refuses
> The `ProductMintRequest` type of version `0.3.0` still declares `owner_email`
> and `contract_address`. Those two fields were removed from the API on
> August 20, 2026. Sending them makes the request fail with 400, while your code
> compiles without a word. Do not fill them in.

### 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`.

> [!ATTENTION] `finished` describes the queue
> This field says that the queue has finished processing the batch. It says
> nothing about the result. A batch whose every row was rejected answers
> `finished` too. To know the result item by item, open the list of products in
> your brand's console. The SDK exposes no method that reads it.

> [!ATTENTION] `unknown` means not found
> That response carries only `job_id` and `status`. An absent `is_finished`
> therefore means that the batch cannot be found. Do not conclude from it that
> the batch is still in progress, your loop would never stop.

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.

> [!DANGER] Two diagnostic fields to ignore
> The response type declares two fields meant for internal diagnostics. Their
> contents are not a contract, they can change without notice and they do not
> describe the result item by item. Do not build any logic on them.

## Telling the two families of errors apart

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

| Class | What happened | What you have |
| --- | --- | --- |
| `SealTrustError` | The API answered and refused. | `status`, `body`, `headers`. |
| `SealTrustNetworkError` | The 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.

| Refusal | Headers | When to retry |
| --- | --- | --- |
| Rate limit | `Retry-After`, plus `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` and `X-RateLimit-Scope` | after the number of seconds given by `Retry-After` |
| Daily quota of the key | `X-Quota-Limit`, `X-Quota-Remaining`, `X-Quota-Reset`. No `Retry-After` | after 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 see | Cause | What to do |
| --- | --- | --- |
| a `finished` batch without knowing how many items went through | this status describes the execution queue, it does not count the items | open the list of products in the console, the SDK exposes no method that reads it |
| 400 on a batch that the compiler accepts | you are sending `owner_email` or `contract_address`, still declared in the type and refused by the API | remove those two fields |
| 401 on `verify.batch()` or `verify.metadataIntegrity()` | these routes expect a console session | do not use these two methods with an API key |
| 403 with scopes named in the message | the key does not have the scope required by the route | create a new key carrying the named scope, from the console |
| 403 on the whole batch | at least one row carries a `brand_id` different from the one of the key | correct the offending row, the whole batch is refused because of a single one |
| 403 `FEATURE_NOT_AVAILABLE` | your plan does not include the function called: access to the API, or notifications on a creation and on a modification of a subscription | check your plan in the console |
| 403 `AUTH_METHOD_NOT_ALLOWED` on a batch | your plan does not allow the `qr` method, which the server sets on every row of this endpoint | check your plan in the console |
| 403 `QUOTA_EXCEEDED` | your brand's monthly product allowance is reached, and this batch would go past it | reduce 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 `undefined` | you are calling a method that is absent from the SDK: `products.list()`, `products.get()`, `verify.product()`, or the `certificates` family | no published version of the package has ever carried them, and the corresponding routes have never existed |
| `undefined` when reading `hook.events` on a list item | the list returns `event_types` | read `item.event_types` on the items of `list()` |
| every event delivered twice | two subscriptions carry the same address, a `create()` was replayed | list your subscriptions and delete the duplicate |
| a waiting loop that never stops | you are testing `is_finished`, absent from the `unknown` response | loop on `status`, and stop as soon as it leaves `queued` and `started` |
| 400 `CONFIRMATION_MISMATCH` on deletion | the address sent does not match the one recorded under this identifier | read the subscription again with `webhooks.get(id)` and pass its `url` |
| 429 with `Retry-After` | the rate limit is reached | wait the number of seconds given by `Retry-After` |
| 429 without `Retry-After` | the key's daily quota is reached | wait for midnight UTC; retrying before then fails the same way |
| 503 | the rate limiting service is momentarily unavailable | retry shortly after, nothing has been processed |
