@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.
Maintainers
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/coreQuick 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.
