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

@endstate-sdk/core

v0.3.0

Published

The Endstate API in TypeScript for Node, edge, and the browser - typed operations, spec-derived retries, and tap sessions, with zero runtime dependencies.

Readme

@endstate-sdk/core

The Endstate API in TypeScript. One build for Node, edge runtimes, and the browser, with zero runtime dependencies.

npm install @endstate-sdk/core

Quick start

import { EndstateClient, secretKey } from "@endstate-sdk/core";

const endstate = new EndstateClient({
  apiKey: secretKey(process.env.ENDSTATE_API_KEY),
});

const item = await endstate.units.create({
  collection_id: "8e1a7f50-90ab-4cde-8012-3456789abcde",
  external_id: "jacket-0001",
  name: "Field Jacket",
});

apiKey is typed `end_sk_${string}`, so a key read from the environment needs narrowing. secretKey() does it, and throws a named error at startup rather than letting a wrong or missing key surface as a 401 on the first call. publishableKey() is its counterpart for EndstatePublicClient.

Field names are the API's own. What you read in the reference is what you get in TypeScript: chip_id, external_id, session_token, has_more, next_cursor. Nothing is renamed.

Pairing a chip

Chips are paired from your server, because secret keys (end_sk_...) never belong in a browser. Get the chip_id, e and c values from a tap - see @endstate-sdk/reader for reading them in an operator UI.

const collection = await endstate.collections.create(
  { external_id: "fw26-outerwear", name: "FW26 Outerwear" },
  { idempotencyKey: "fw26-outerwear" },
);

const item = await endstate.units.create(
  { collection_id: collection.id, external_id: itemRef },
  { idempotencyKey: itemRef },
);

await endstate.chips.pair(
  { unit_id: item.id, chip_id: chipId, e, c },
  { idempotencyKey: `pair-${itemRef}` },
);

const ready = await endstate.units.waitUntilIssued(item.id);

Pass an idempotencyKey you already have - the item's own external_id works well. Re-running the pipeline then replays the original response instead of creating a second item, and the same key makes a retry safe within the call too. Omit it and the SDK generates one, which covers a retry but not a re-run.

Creating twice under different keys returns unit.already_exists, so external_id remains the durable guard.

Verifying a tap

verify() records the tap and hands back a session scoped to it, so a session token is never something your code has to carry around.

import { EndstatePublicClient } from "@endstate-sdk/core";

const endstate = new EndstatePublicClient({
  publishableKey: "end_pk_live_...", // safe in a browser
});

const session = await endstate.verify({ chip_id, e, c });

console.log(session.item?.name); // the verified item, no extra request
const claim = await session.claims.create({ to: recipient });
await session.claims.waitUntilSettled(claim.id);

verify() takes the identified tap, never a URL. To read one out of the page URL - a tap URL, or a tap-redirect destination on your own domain - parse it first. tryParseTapUrl returns null rather than throwing, so "no tap on this page" is a branch instead of an exception:

import { tryParseTapUrl } from "@endstate-sdk/core";

const tap = tryParseTapUrl(window.location.href);
if (!tap) return renderTapPrompt();

const session = await endstate.verify(tap);

The tap credential e works exactly once. There is no session refresh: a new session needs a new tap.

Errors

Every failure carries a stable, namespaced code. Branch on it, never on the message or the HTTP status.

import { isEndstateError, isValidationError } from "@endstate-sdk/core/errors";

try {
  const session = await endstate.verify(tap);
  render(session.item);
} catch (error) {
  if (isEndstateError(error, "chip.already_scanned", "chip.invalid_e_value")) {
    showTapAgain(); // the credential is spent or malformed
  } else if (isEndstateError(error, "chip.not_found")) {
    showNotRecognized(); // not one of yours
  } else if (isValidationError(error)) {
    showFieldErrors(error.details.fieldErrors);
  } else if (isEndstateError(error)) {
    report(error.requestId); // always log this
  } else {
    throw error;
  }
}

Codes are added over time, so an unrecognized one is preserved verbatim rather than thrown away. error.requestId matches the X-Request-Id response header; include it in any support request.

Retries and idempotency

Retries and idempotency keys ship together, because a repeated write without a key is a duplicate write. What this SDK will and will not repeat:

| Call | Repeated when no response arrived? | Repeated after a 5xx? | | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- | | Any GET | Yes | Yes | | units.create, collections.create, chips.pair, chips.pairBulk, chipReplacements.create, claims.create, transfers.create, taps.create / verify | Yes, with one key reused across every attempt | No | | units.update, collections.update, settings.update, settings.corsOrigins.replace, sessionTokens.revoke, testHelpers.createTap | No | No |

The first split comes from the API spec: only the calls that declare an Idempotency-Key can be deduplicated server-side, so only those are ever sent again. The second is the API's own rule: it clears the idempotency record on any non-2xx response, so repeating a write after a 5xx would execute it a second time instead of replaying the first. Raising maxAttempts changes neither. A keyed call is repeated on one further signal, a 409 idempotency.in_progress: an identical call is still in flight, so the retry waits for it and picks up its result rather than starting a second write.

The one exception is a rate-limit rejection. A 429 carrying rate_limit.exceeded is refused before the request reaches any handler, so nothing was written and every call is retried after the Retry-After delay. A 429 from a proxy in front of the API carries no such proof and is treated like any other failure.

One key is generated per logical operation and reused on every retry of it. For verify() the key is derived from the tap itself, so a page refresh replays the original response instead of spending the credential and reporting a false "already scanned".

When a call fails before a response arrives, error.safeToRetry says whether calling again on its own recovers the same operation. It is false whenever the SDK generated the key, because a fresh call generates a different one and the API would treat it as a second write. error.idempotencyKey carries the key back regardless: pass it in and the API replays the original response instead of starting that second write.

try {
  await endstate.chips.pairBulk(batch);
} catch (error) {
  if (isEndstateError(error) && "idempotencyKey" in error) {
    await endstate.chips.pairBulk(batch, {
      idempotencyKey: error.idempotencyKey!,
    });
  } else {
    throw error;
  }
}

Pass your own key when you have a better identifier:

await endstate.chips.pairBulk(batch, { idempotencyKey: `batch-${batchId}` });

Tune the rest per client or per call:

new EndstateClient({
  apiKey,
  timeoutMs: 15_000,
  retry: { maxAttempts: 5, baseDelayMs: 500 },
});

await endstate.units.get(id, { retry: false, signal: controller.signal });

Pagination

List calls are both awaitable and iterable.

const page = await endstate.units.list({ limit: 100 });
page.units; // one page, exactly as the API returns it
page.pagination.next_cursor;

for await (const item of endstate.units.list()) {
  // every item, across every page
}

const first50 = await endstate.units.list().all({ maxItems: 50 });

Cursors are opaque. Pass them back unchanged; never parse one.

Tap sources

TapSource is the contract a tap reader satisfies. Core declares it and dispatches to it; it never imports an implementation, so a source can live in any package, including your own.

import type { TapSource } from "@endstate-sdk/core";

const manualEntry: TapSource = {
  name: "manual",
  priority: -10,
  isAvailable: () => true,
  async capture() {
    const url = await promptForTapUrl();
    return url ? parseTapUrl(url, "manual") : null;
  },
};

const endstate = new EndstatePublicClient({
  publishableKey,
  tapSources: [manualEntry],
});

await endstate.availableTapSources(); // capability detection
const tap = await endstate.captureTap(); // call inside a user gesture
if (tap) await endstate.verify(tap);

Sources are registered per client, so server rendering never shares one.

Environments

baseUrl is explicit and defaults to production. Core does not read your credential's prefix and keeps no environment table.

import { ENDSTATE_STAGING_API_URL } from "@endstate-sdk/core";

new EndstateClient({ apiKey, baseUrl: ENDSTATE_STAGING_API_URL });

Anything not covered by a method

Every operation in the spec is reachable by id, typed to the credential you constructed the client with:

const { data, meta } = await endstate.requestWithMeta("getUnit", {
  path: { unit_id: id },
});
meta.requestId;
meta.rateLimit.remaining;
meta.idempotentReplayed;

Wire types are exported from @endstate-sdk/core/types, including the raw paths / operations / components for interop with other OpenAPI tooling.

Requirements

Node 20 or later, any modern browser, or an edge runtime. The package needs fetch and Web Crypto, and nothing else.

Secret keys (end_sk_...) are server-side only. Publishable keys (end_pk_...) are the browser credential: they identify your organization and grant no access on their own.