Skip to content
IN THIS GUIDE

GraveMint SDK ​

@solanadeads/gravemint · version 1.3.0 · install with npm i @solanadeads/gravemint

Guide adapted from the published README for clarity. Code examples are preserved.

Every method, with its signature and types: method reference.

The GraveMint SDK provides collection reads and wallet-signed mint flows for your application. It uses native fetch, includes TypeScript declarations and supports ESM and CommonJS. Bring your existing wallet library; no chain SDK is bundled.

The versioned API is additive-only. See Versioning.

bash
npm install @solanadeads/gravemint

Partner keys are invite-only. Request access for your collections and origins; see Getting a key.


Transaction submission ​

prepare() builds the transaction. Your wallet signs it, and execute() returns the signed bytes to GraveMint for validation and submission.

GraveMint checks the prepared instructions, accounts and amounts before broadcasting. Use the SDK's execute methods rather than broadcasting through your wallet library.


Contents ​


Quick start ​

ts
import { GraveMintClient } from "@solanadeads/gravemint";

const gm = new GraveMintClient({ apiKey: "gm_pub_..." });

// 1. Everything needed to render the drop, in one call.
const drop = await gm.v1.collection("deads");

console.log(drop.collection.name);
console.log(drop.stats.mintedCount, "/", drop.stats.totalSupply);

// 2. Which phase is live right now (server-decided, not client-computed).
const phase = drop.phases.active[0];

// 3. Can this wallet mint it?
const verdict = await gm.v1.eligibility(drop.collection.id, phase.id, wallet);

// 4. Mint. You supply the signer; GraveMint broadcasts.
const result = await gm.mint.mint({
  collectionId: drop.collection.id,
  phaseId: phase.id,
  quantity: 1,
  walletAddress: wallet,
  signer,
});

Getting a key ​

Request a partner key for the collections and origins your integration will use.

Choose from three key classes:

ClassPrefixWhere it belongsWhat bounds it
Publishablegm_pub_your web page — it is meant to be visiblethe origin allow-list on the key
Secretgm_live_your server onlysecrecy — it has no origin binding
Sandboxgm_test_development against test-network drops onlyrefused on mainnet (SANDBOX_KEY_ON_MAINNET); production keys are refused on test networks (LIVE_KEY_ON_TESTNET)

Publishable keys are intended for browser bundles. Register every requesting origin, including staging and previews, and keep the collection scope limited to your integration.

Keep gm_live_ keys on your server. They do not use browser-origin binding.

Use gm_live_ for server calls. Browser keys require a matching Origin header; a request without one returns ORIGIN_NOT_ALLOWED.


Configuration ​

ts
new GraveMintClient({
  apiKey:     "gm_pub_...",   // required
  timeoutMs:  30_000,
  maxRetries: 3,              // 429 / 5xx / network
  rateLimit:  { requestsPerSecond: 5, burst: 10 },   // null to disable
  fetch:      globalThis.fetch,
});

Base URLs ​

The versioned contract is a separate mount from the legacy public read API, with its own auth and its own middleware. The client keeps two base URLs:

defaultused by
baseUrlhttps://api.deads.io/gravemint/public (api.solanadeads.com serves the same origin and keeps working)the legacy read API
v1BaseUrlhttps://api.solanadeads.com/gravemint/v1gm.v1, gm.mint

You normally set neither. If you set baseUrl to something ending in /public, v1BaseUrl is derived for you. If it ends in anything else — because you are routing through your own proxy — the constructor throws and asks you to pass v1BaseUrl explicitly.

Explicit proxy configuration lets the client validate its API routes at startup.

ts
// Proxying through your own backend:
new GraveMintClient({ apiKey, v1BaseUrl: "https://yoursite.com/api/gravemint" });

Node <18 needs a fetch polyfill: new GraveMintClient({ apiKey, fetch }).


Building a mint page ​

Read collection data, phases, eligibility and supported features before enabling minting.

gm.v1.collection(identifier) returns all of it in one call. identifier is a shortId, a collection UUID, or an on-chain address.

Which one to use:

  • Anything a person sees (URLs, share links): the shortId or the on-chain address.
  • API calls: drop.collection.id, the UUID. Every v1 route accepts it, so passing it everywhere is always safe. Until gravemint#4493, some read routes answered a UUID with 404; they now accept it.
  • gm.mint.prepare() (and gm.mint.mint()) also take the shortId or on-chain address as collectionId (gravemint#4525). It is resolved within your key's own drops only; anything else is refused as not in scope. Pass an address exactly as v1 returned it: it is matched as-is, not case-folded. If two of your drops share an on-chain address, that address resolves to neither, so use the shortId or UUID.
ts
const drop = await gm.v1.collection("deads");

drop.collection    // name, symbol, image, chain, socials, isVerified
drop.stats         // totalSupply, mintedCount, availableCount, percentMinted
drop.phases        // { all, active, upcoming }
drop.capabilities  // can you render this drop faithfully?
drop.serverTime    // our clock — see below

Server-resolved values ​

Use the returned pricing, eligibility and phase status values. GraveMint evaluates the collection and wallet settings on the server; refresh these reads when the wallet, selected phase or mint state changes.

Phases and countdowns ​

ts
for (const phase of drop.phases.all) {
  phase.status            // "active" | "upcoming" | "ended"
  phase.name
  phase.startDate         // ISO, or null
  phase.endDate
  phase.maxPerWallet      // null = no cap
  phase.maxPerTransaction
  phase.phaseSupply
  phase.isGated           // does it have eligibility requirements?
  phase.bogo              // { buy, get } or null
  phase.msUntilStart      // ← render countdowns from these
  phase.msUntilEnd
}

phases.all includes ended phases, which you can display in the full schedule or filter from an active view.

Use msUntilStart and msUntilEnd for countdowns. Anchor a ticking display to drop.serverTime, then refresh the collection response when a countdown expires.

Price is a shape, not a number ​

ts
type PriceDisplay =
  | { kind: "amount"; amount: number; currency: string; isFree: boolean; approximate?: boolean }
  | { kind: "range";  min: number; max: number; currency: string }
  | { kind: "hidden" }
  | { kind: "unknown" };

Render it by branching, and treat all four as normal:

tsx
switch (phase.priceDisplay.kind) {
  case "amount":
    return phase.priceDisplay.isFree
      ? <>Free</>
      : <>{phase.priceDisplay.approximate ? "~" : ""}{phase.priceDisplay.amount} {phase.priceDisplay.currency}</>;
  case "range":
    return <>{phase.priceDisplay.min}–{phase.priceDisplay.max} {phase.priceDisplay.currency}</>;
  case "hidden":
    return <>Price revealed when you qualify</>;
  case "unknown":
    return <>Price shown at checkout</>;
}

priceDisplay represents the price available for display before a mint is prepared. It accounts for the phase's pricing settings. Hidden prices remain hidden until the wallet qualifies; unknown prices have no display amount for the request.

Show “Free” only for an amount with isFree: true. Do not replace hidden or unknown values with zero.

The final wallet-specific breakdown is returned by prepare() as pricing, including applicable benefits and fees.

Eligibility ​

ts
const v = await gm.v1.eligibility(collectionId, phaseId, walletAddress);

v.gated              // does this phase gate at all?
v.meetsRequirements  // the verdict
v.onAllowlist
v.spotsAllocated
v.spotsRemaining
v.requirements       // [{ type, met, description }] — render these
v.phase              // { id, name, hasStarted, hasEnded }
v.message

This is server-evaluated because it has to be: the dominant gate is an NFT-holding check needing an on-chain lookup, and an allowlist must never be published to a client.

meetsRequirements reports wallet requirements separately from the time window. Check phase.hasStarted and phase.hasEnded as well before enabling minting. For ungated phases, use gated: false as the requirements check.

ts
const canMintNow = v.meetsRequirements && v.phase.hasStarted && !v.phase.hasEnded;

The rest of the drop — art, feed, bounty, per-wallet counts ​

These are all scoped to your key the same way collection() is: a key issued for another drop gets 403 COLLECTION_NOT_IN_SCOPE, never an empty list.

ts
await gm.v1.gallery("deads", { limit: 24 });   // NFTs still available to mint
await gm.v1.recentlyMinted("deads");           // the live "just minted" feed
await gm.v1.bounty("deads");                   // bounty summary, when there is one
await gm.v1.bountyPrizes("deads");             // its public prize table
await gm.v1.walletMints("deads", wallet);      // "you have minted 2 of 3", per phase

walletMints includes bonus mints such as BOGO and bounty mints. Eligibility uses separate allocation rules. Use the wallet-mint response when displaying minted or remaining counts.

Claim codes ​

Claim-code endpoints describe the collection’s code configuration and the benefits a wallet has already redeemed.

ts
await gm.v1.claimCodes(collectionId);                          // does this drop use codes?
await gm.v1.claimCodeBenefits(collectionId, wallet);           // what has this wallet earned?
await gm.v1.validateClaimCode(collectionId, 'CODE-123');       // { valid, grantType, ... }

validateClaimCode checks a code without redeeming it or consuming a use. A code outside the collection returns the same response as an unknown code.

prepare applies benefits already redeemed by the minting wallet for this collection. It does not accept a claim code. The collector redeems codes on the hosted GraveMint page, where the required checks and signature are handled.

To display benefits before minting, call claimCodeBenefits(collectionId, wallet, { phaseId }). Include the selected phase and, for a pack drop, packTierId to restrict the calculation appropriately. The prepare response's pricing remains the final amount.

Pricing helpers ​

ts
await gm.v1.tokenPrices();            // platform token prices, for an SPL-priced drop
await gm.v1.peggedPrice(phaseId);     // resolved amount for a USD/native-pegged phase
await gm.v1.dutchPrice(phaseId);      // live price of a dynamic Dutch phase

Render phase.priceDisplay — it already resolves Dutch and pegged prices (a pegged figure comes back approximate). v1 phases carry no raw price field; these two helpers are for refreshing a live number without re-reading the whole drop.

Both phase routes are scoped through the phase's own collection: a phase id is not a way around collection scope.

Supply, traits and social proof ​

ts
await gm.v1.availability(collectionId);   // approximate supply counts — a progress bar, never a sold-out decision
await gm.v1.traits(identifier);           // trait names and values, for gallery filters
await gm.v1.allMinted(identifier, { limit: 100, offset: 0 });  // ONE page (default 20, max 100) — page with offset
await gm.v1.topHolders(identifier);       // largest holders
await gm.v1.topMinters(identifier);       // who minted the most

Capabilities — when you cannot render a drop ​

ts
drop.capabilities  // { requiresFeatures: string[], supportedBySurface: boolean, mintUrl: string | null }

v1 covers core mint: supply, phases, fixed and pegged pricing (including BOGO and hidden prices), claim-code benefits already redeemed, eligibility, per-wallet limits, and minting on Solana. requiresFeatures names what a drop needs beyond that; any of these makes supportedBySurface false:

FeatureWhy the drop is not core-renderable
packsa phase sells pack tiers
gallery_modethe collector chooses which NFT to mint
nft_selectiona phase restricts which NFTs can be minted (by rarity, trait, or a specific list)
generativea generative collection — traits are composed at mint and the price carries a surcharge
dutch_auction, dynamic_dutchthe live price decays or surges, so priceDisplay is a range or unknown, not a price tag
cross_chainpayment on another chain, which partner surfaces never support
non_solana_chainthe drop is not on Solana — v1 mints Solana only
claim_codesthe drop has active claim codes (a collection-level fact — codes are redeemed against the drop)

This list only grows. Branch on supportedBySurface, not on the names.

tsx
if (!drop.capabilities.supportedBySurface) {
  return <a href={drop.capabilities.mintUrl}>Mint on GraveMint</a>;
}

Handle unsupported capabilities before enabling minting, so collectors can use the hosted page for features outside your integration.

Collections with unrecognized requirements are marked unsupported. Use mintUrl when provided to open the hosted collection page.


Minting ​

ts
const prepared = await gm.mint.prepare({
  collectionId, phaseId, quantity: 1, walletAddress,
  affiliateCode,        // optional; validated server-side, ignored if unused
  walletProvider: wallet.adapter.name, // optional; fills your wallet breakdown, else "Unknown"
});

const signed = await signer.signTransaction(prepared.transactions[0].transaction);

const result = await gm.mint.execute({
  sessionId: prepared.sessions[0].sessionId,
  signedTransaction: signed,
});

prepare returns transactions[] and sessions[], including for quantity one. Read sessions[0].sessionId for the first transaction. mint() handles the pairing for you.

Or in one call:

ts
const result = await gm.mint.mint({
  collectionId, phaseId, quantity: 1, walletAddress, signer,
});

prepare() does the real work — eligibility, limits, pricing, supply reservation, transaction build — and returns pricing (the authoritative breakdown), sessions[] (one per transaction), expiresAt / expiresInMs, and quantityAdjusted — true when fewer NFTs were prepared than requested (a wallet or phase limit, or supply). Tell the collector before they sign; adjustmentReason says why.

execute waits up to 130 seconds by default — longer than the server's own budget, which can still be broadcasting after a shorter client gives up — unless you set a client-wide timeoutMs, which is respected. It is never retried automatically: re-sending signed transactions gets ALREADY_PROCESSING / ALREADY_COMPLETED back, which would hide whether the first attempt landed.

If gm.mint.mint() loses the answer AFTER the signed transaction left your client — a timeout, a dropped connection, any 5xx (including an edge timeout such as Cloudflare's 524), the server's own 408, or ALREADY_PROCESSING / ALREADY_COMPLETED / RECONCILE_PENDING / MINT_PAYMENT_UNVERIFIED — it throws MintOutcomeUnknownError (code: "OUTCOME_UNKNOWN", sessionIds). The mint may still land. Do not retry: a retry prepares again and can mint twice. Show its message ("check your wallet before trying again"). A 5xx refused before broadcast looks the same from here, so it is reported as unknown too — the safe direction.

mint() also returns (does not throw) when a batch confirmed only some, or none, of its transactions: { success: false, partial, message }. Anything but success: true is not a success.

Sessions expire. Do not hold a signature and submit it later; prepare again. A prepared session also reserves supply, so do not call prepare() just to read a price — that is what priceDisplay is for.

The signer ​

ts
interface TransactionSigner {
  signTransaction(base64Transaction: string): Promise<string>;
}

The signer accepts a base64 transaction and returns the signed transaction in base64. You can also supply signAllTransactions to request batch approval.

Wallet-adapter example ​

ts
import { VersionedTransaction } from "@solana/web3.js";

const signer = {
  async signTransaction(base64: string) {
    const tx = VersionedTransaction.deserialize(Buffer.from(base64, "base64"));
    const signed = await wallet.signTransaction(tx);
    return Buffer.from(signed.serialize()).toString("base64");
  },
};

For legacy transaction support and browser Buffer setup, see the signer adapter.

Chain support ​

The v1 mint flow supports Solana. Check capabilities.supportedBySurface before preparation and offer mintUrl for collections that require another flow.


Errors and retries ​

Every error carries a machine-readable code, a human message safe to show a collector, plus .status and .requestId for support.

ts
import { GraveMintError, RateLimitError } from "@solanadeads/gravemint";

try {
  await gm.mint.mint({ ... });
} catch (err) {
  if (err instanceof GraveMintError) {
    console.error(err.code, err.requestId);
    showToast(err.message);      // safe: no internals, no schema details
  }
}

Error handling ​

The API's stable v1 error catalog covers authentication, scope and collection lookups. Mint handlers and middleware can return additional codes, including NO_NFTS_AVAILABLE, PHASE_ENDED, WALLET_LIMIT_EXCEEDED, SESSION_NOT_FOUND, INSUFFICIENT_FUNDS and NOT_WHITELISTED.

Handle known codes explicitly and keep a message fallback for additional or code-less errors.

For batch outcomes, read each transaction's errorCode from the returned results array. A batch HTTP response can succeed even when individual transactions fail or remain pending.

ts
import { isBatchMintResult } from "@solanadeads/gravemint";

const res = await gm.mint.mint({ ... });
if (isBatchMintResult(res) && res.success === false) {
  // res.partial: did SOME land? res.minted of res.requested; res.message is safe to show.
  for (const r of res.results) {
    if (!r.success) console.warn(r.sessionId, r.errorCode);  // e.g. TX_MODIFIED
  }
}

Branch on documented stable codes where your application needs specific recovery behavior. Display the returned message for other responses.

ts
const code = err instanceof GraveMintError ? err.code : undefined;
if (code === "TX_MODIFIED") { /* never retry — see below */ }
else if (code === "ORIGIN_NOT_ALLOWED") { /* config: send us the origin */ }
else { showToast(err.message); }   // covers the unfrozen and code-less cases

Stable v1 codes ​

CodeMeaningRetry?
COLLECTION_NOT_FOUNDno such drop, or not publicly servableno
LOOKUP_FAILEDthe collection lookup could not completeyes, backoff
COLLECTION_REQUIREDthe request named no collectionno — fix the call
CODE_REQUIREDa claim-code check arrived with no codeno — fix the call
API_KEY_REQUIRED / API_KEY_INVALIDmissing or wrong keyno — fix config
API_KEY_INACTIVE / API_KEY_EXPIREDkey revoked or lapsedno — talk to us
ORIGIN_NOT_ALLOWEDvalid key, unregistered origin (or a gm_pub_ key used server-side)no
COLLECTION_NOT_IN_SCOPEvalid key, not issued for this dropno
AUTH_UNAVAILABLEthe key check could not completeyes, backoff

Also frozen, and emitted by the shared mint handlers: COLLECTION_CLOSED · NFTS_LOCKED · SESSION_EXPIRED · TX_MODIFIED.

For single mints, a transaction failure is exposed as a thrown error code. Batch mint outcomes use results[].errorCode; inspect those returned results as well as thrown errors.

Use server submission for all v1 mints. The following codes require changing the request rather than resubmitting it:

  • CLIENT_BROADCAST_NOT_ALLOWED — the request carried transactionHash (or txHash / transaction_hash). Sign the transaction prepare-mint returned and send it back as signedTransaction; we submit it and confirm it.
  • CHAIN_NOT_SUPPORTED_BY_SURFACE — the drop is on a chain this API does not serve. v1 is Solana-only. This is distinct from CHAIN_DISABLED, which means a chain was retired platform-wide; your drop's chain is fine, it is just out of scope here. Use mintUrl from the collection response to send the collector to GraveMint instead.

Frozen but not emitted on the v1 path: CHAIN_DISABLED · UNSUPPORTED_BY_SURFACE (not emitted anywhere yet), and NOT_ELIGIBLE · WALLET_MISMATCH (emitted elsewhere in GraveMint, on the cross-chain and mint-page-token paths — neither of which v1 mounts). They will not be renamed.

The eligibility endpoint ​

gm.v1.eligibility() reuses the first-party handler, so it has its own two codes. Neither is frozen:

CodeMeaningRetry?
INVALID_ADDRESSthe wallet address is not valid for this chainno — fix the call
CHECK_BUSYthe eligibility engine is at its global budgetyes, short backoff

Rate limiting and suspension ​

CodeMeaningRetry?
RATE_LIMITEDtoo many requests for this keyyes, honour Retry-After
COLLECTION_RATE_LIMITEDtoo many prepares against this collection, from everyoneyes, honour retryAfter
WALLET_BLOCKEDthis wallet is suspendedno — on prepare-mint the body also carries reason / blockedUntil / permanent; on execute-mint it is the code and message only
WALLET_HELDa wallet this request is made by or for — the signed-in wallet, a minting or recipient wallet named in the body, or the wallet behind the mint session — is on a security hold (reported compromised)no — show the message; the collector must contact SolanaDeads
WALLET_HOLD_UNVERIFIEDholds are active and we could not confirm the wallet behind this request (typically: a mint session's wallet could not be read), so we refused rather than guess (HTTP 503)yes, backoff
ASSET_BANNEDthis collection or token is on the platform ban listno

Bot protection and the server's time budget ​

Every v1 request passes the platform's bot protection and a per-request time budget. None of these is fixed by retrying the same request immediately:

CodeMeaningRetry?
BOT_BLOCKEDthis client was blocked by bot protectionno
BOTNET_DETECTEDthe request's signature (e.g. its User-Agent) matches known automation toolingno — from a server, send a descriptive User-Agent
WALLET_BOT_BLOCKEDthe wallet in this request is restricted by bot protectionno
BOT_DETECTEDthe request scored as automatedno — back off
REQUEST_TIMEOUT (HTTP 408)the server's time budget ran out — the work may still finishreads: yes, backoff. execute-mint: never — mint() reports it as OUTCOME_UNKNOWN

Key class and scope ​

A key carries a class (live or sandbox) and a list of scopes. These are refusals of the key for this request, not of the drop, so none of them is fixed by retrying:

CodeMeaningRetry?
SCOPE_NOT_GRANTEDthe key is valid but was not issued the scope this call needs (e.g. mint)no — ask us to add the scope
SANDBOX_KEY_ON_MAINNETa sandbox (gm_test_) key was used against a mainnet dropno — use a production key: gm_pub_ in a browser, gm_live_ on a server
LIVE_KEY_ON_TESTNETa production key (gm_pub_ or gm_live_) was used against a test-network dropno — use your sandbox (gm_test_) key

These codes distinguish test-network and production credentials.

TX_MODIFIED means the signed transaction differs from the prepared transaction. Check the adapter for changed instructions or accounts, then prepare a new transaction. Do not resend the modified bytes.

ORIGIN_NOT_ALLOWED requires origin registration; API_KEY_REQUIRED indicates a missing credential.

The SDK retries 429/5xx/network automatically — except execute, which it never replays (see "Minting") — with exponential backoff, honouring Retry-After, up to maxRetries. It does not auto-retry the terminal codes above.

Rate limits ​

LimitValueScope
Reads on /v1 (shared with /gravemint/public)600 / minuteper IP
/v1/prepare-mint and /v1/execute-mint (shared bucket)60 / minuteper key
prepare-mint, per COLLECTION30 / 10sper collection, shared with first-party traffic
validateClaimCode (claim-code check)20 / minuteper IP, shared with first-party code checks
SDK client-side limiter5 rps sustained, 10 burstyour process

Account for each limit when sizing request queues. A shared limit can apply before a per-key limit is reached.

Two sharing details that matter in practice:

  • prepare and execute share the same 60/min bucket, and a mint needs one of each — so the sustained ceiling is roughly 30 mints per minute per key, not 60.
  • The read limiter is the same instance /gravemint/public uses, so v1 reads and first-party public reads from one IP draw on one 600/min bucket.
  • The per-collection prepare cap is shared with gravemint.io. It is keyed on the collection alone, so on a hot drop your prepares compete with every first-party collector's — and on a busy launch it can bind well below the 60/min-per-key figure. Treat 60/min as a ceiling you may not reach, not a reservation.

The mint limit is keyed on the API key, not the IP, so a partner proxying every collector through one server is not sharing a bucket with the rest of the internet — but it does mean all of that partner's collectors share it.

  • The claim-code check is per IP, not per key. Validate from the collector's browser. A partner that validates from its own server (a gm_live_ key) sends every collector's check from one address, so all of its users share 20 a minute.

On a 429 the SDK honours Retry-After — or, when a limiter sends it only in the body, retryAfter — and retries up to maxRetries. The server body is on the thrown error as err.details. Disable the client-side limiter with rateLimit: null only if you have your own queue. Contact us if your integration needs a higher limit.


Versioning ​

v1 is additive-only. Within the major we will not remove a field, change a field's type, rename an error code, or tighten validation. New behaviour arrives as new fields or a new version. Unversioned legacy paths keep working.

Pin the package version in your application and use capability checks to handle collections that require newer or additional features.

ts
await gm.v1.version();   // { version: "v1", stability: "additive-only", docs }

x-gm-client identifies the SDK version on each request, including browser requests.

Deprecations will be announced with a migration path and a real timeline, and old majors keep working while both are live.


Legacy read API ​

The original read-only surface is unchanged and still supported:

ResourceMethods
gm.discoverygetFeatured, getLiveMints, getTrending, getUpcoming, getRecentSoldouts, listCollections, searchCollections
gm.collectionsget, getGallery, getRecentlyMinted
gm.nftsget
gm.bountygetSummary, getPrizes
gm.platformgetCapabilities, getTokenPrices
gm.codesvalidate (read-only — does not redeem)

These legacy resources use the first-party public API and are not available to partner keys. Use the scoped gm.v1 methods for partner collection reads and gm.mint for minting.

Cross-collection discovery remains first-party. The v1 equivalents for collection data include collection, eligibility, gallery, recentlyMinted, bounty, bountyPrizes and walletMints.

Use gm.v1.collection() for display prices and capability checks.


Security notes for partners ​

  • Register the exact origins used by your browser application, including staging and previews.
  • Keep gm_live_ keys on your server and request revocation if a key is exposed.
  • Use the API's resolved pricing and eligibility results.
  • Return signed transactions to GraveMint for submission.

Development ​

bash
npm test          # unit tests, no network
npm run typecheck
npm run build     # dual ESM + CJS

License ​

MIT