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

@ledewire/node

v0.18.0

Published

LedeWire SDK for Node.js — full API surface for building merchant stores

Readme

@ledewire/node

npm License: MIT

Node.js SDK for the LedeWire content marketplace — full API surface for building merchant stores, managing store content, and processing buyer flows on the server side.

Install

npm install @ledewire/node

Quick Start

import { createClient } from '@ledewire/node'

// Store API key: key + secret grants read/write access to client.merchant.*
const client = createClient()
// loginWithApiKey resolves with the key's store_id: pass it to client.merchant.*
const { store_id } = await client.auth.loginWithApiKey({
  key: process.env.LEDEWIRE_API_KEY,
  secret: process.env.LEDEWIRE_API_SECRET,
})
const summary = await client.merchant.sales.summary(store_id)
const items = await client.merchant.content.list(store_id)

// Or pass the key to createClient: it logs in by itself before the first
// authenticated request (and again if a refresh fails), so no login call is needed.
const keyClient = createClient({
  apiKey: process.env.LEDEWIRE_API_KEY,
  apiSecret: process.env.LEDEWIRE_API_SECRET,
})
const sales = await keyClient.merchant.sales.list(storeId)

// Merchant email/password auth
const client = createClient()
await client.merchant.auth.loginWithEmail({ email, password })
const stores = await client.merchant.auth.listStores()

Client Namespaces

| Namespace | Description | | ------------------------------ | --------------------------------------------------------------------------------------------- | | client.config | Platform-level public config (no auth required) | | client.auth | Buyer signup, email/password login, Google OAuth, password reset, store invitation lookup | | client.wallet | Buyer wallet balance (incl. held_cents/holds from bulk acquisitions) and payment sessions | | client.purchases | Buyer purchase history, create purchases, verify ownership | | client.content | Fetch content with buyer access info; search every Store and Publication | | client.checkout | Checkout state — what action is required next | | client.user.apiKeys | Manage buyer API keys for autonomous agents | | client.user.spendCap | Buyer's daily spend cap — read and update the ceiling | | client.user.mcpKeys | Manage buyer MCP API keys for the Ledewire MCP server | | client.user.paymentKeys | Register, list and revoke the Ed25519 Payment keys that sign x402 payments | | client.company | Company wallets: membership, invitations, members, Machine users, top-ups, spend reports | | client.publications | Bulk-licensing catalog: publications and their works (public) | | client.topics | Topic directory: the subjects publications cover, with how many carry each (public) | | client.acquisitions | Bulk licensing: quote, authorize, corpus download, signed manifest | | client.x402 | Public x402 Bazaar discovery, origin verification and the accessToken JWKS (no auth required) | | client.merchant.auth | Merchant login (email / Google), store discovery, password reset, accept a store invitation | | client.merchant.users | Merchant user management (invite, list, update, remove) | | client.merchant.content | Content CRUD + search (merchant JWT or store API key) | | client.merchant.buyers | Buyer statistics within a store | | client.merchant.sales | Sales reporting and revenue statistics | | client.merchant.config | Store configuration | | client.merchant.domains | x402 domain verification for URL-based content gating | | client.merchant.pricingRules | x402 URL pattern-based pricing rules |

Configuration

const client = createClient({
  apiKey: process.env.LEDEWIRE_API_KEY,
  apiSecret: process.env.LEDEWIRE_API_SECRET, // omit for read-only store API-key access
  baseUrl: 'https://api-staging.ledewire.com', // optional, defaults to production

  // Persist tokens across server restarts (optional)
  storage: {
    getTokens: async () => JSON.parse((await redis.get('lw:tokens')) ?? 'null'),
    setTokens: async (t) => redis.set('lw:tokens', JSON.stringify(t)),
    clearTokens: async () => redis.del('lw:tokens'),
  },

  // Side-effects only — storage.setTokens is already the persistence hook.
  // Use onTokenRefreshed for audit logging or cache invalidation on refresh.
  onTokenRefreshed: async (tokens) => {
    await auditLog.record('token_refreshed', { expiresAt: tokens.expiresAt })
  },

  onAuthExpired: () => {
    console.error('LedeWire session expired — re-authenticate')
  },
})

Token refresh is handled automatically — you never need to call a refresh method manually.

apiKey / apiSecret log in automatically: construction sends nothing, and the client logs in with the key (POST /v1/auth/login/api-key) before its first authenticated request when no token is stored. Concurrent first requests share one login. If a refresh fails later, the client logs in with the key again and retries the request once. onAuthExpired fires only when an automatic login fails, once per failed attempt. A refused key rejects the request with an AuthError carrying the API's message. A client that already holds a token (from storage or an explicit login) doesn't log in.

On a client created with apiKey, clearing the stored tokens doesn't drop the credentials: the next request logs in again. To stop for good, create a client without apiKey.

Don't mix another login (email, Google, merchant) on an apiKey client: if that session's refresh fails, the client falls back to the store key.

To get the key's store_id, call client.auth.loginWithApiKey() once yourself, as in the quick start: it resolves with store_id, and the client, now holding a token, doesn't log in again.

Serverless / edge note: The default MemoryTokenStorage resets on every cold start, which means tokens are lost between function invocations. Always provide a custom storage adapter (database, Redis, encrypted cookie) when deploying to serverless or edge runtimes.

Example: Merchant JWT Auth (no API key)

Use this flow when running a merchant backend that authenticates via email/password or Google. No API key is required. Token refresh is automatic — the SDK handles it transparently.

Email / password login

The one-step helper logs in and returns both the normalized tokens and the accessible stores list in a single HTTP call:

import { createClient, ForbiddenError } from '@ledewire/node'

const client = createClient({
  // Required for serverless/edge — MemoryTokenStorage (default) resets on cold start.
  storage: {
    getTokens: async () => JSON.parse((await redis.get('lw:tokens')) ?? 'null'),
    setTokens: async (t) => redis.set('lw:tokens', JSON.stringify(t)),
    clearTokens: async () => redis.del('lw:tokens'),
  },
  onAuthExpired: () => redirect('/login'),
})

try {
  const { tokens, stores } = await client.merchant.auth.loginWithEmailAndListStores({
    email: '[email protected]',
    password: process.env.MERCHANT_PASSWORD,
  })
  // tokens: StoredTokens — { accessToken, refreshToken, expiresAt: number (Unix ms) }
  // stores: MerchantLoginStore[] — use .id, .name, .role
  const storeId = stores[0].id
} catch (err) {
  if (err instanceof ForbiddenError) {
    // Valid credentials but account has no merchant store access (e.g. buyer account).
    // err.message: "This account does not have merchant access. Use a merchant or owner account."
  }
}

Google OAuth login

Same flow with a Google ID token instead of email/password:

const { tokens, stores } = await client.merchant.auth.loginWithGoogleAndListStores({
  id_token: googleIdToken, // from Google Identity Services callback
})
const storeId = stores[0].id

Password reset

Two-step flow — request a code, then submit it with the new password:

// Step 1 — send a 6-digit reset code to the merchant's email.
// Always returns 200 to prevent email enumeration.
await client.merchant.auth.requestPasswordReset({ email: '[email protected]' })

// Step 2 — submit the code and new password.
await client.merchant.auth.resetPassword({
  email: '[email protected]',
  reset_code: '246810',
  password: 'new-secure-password',
})

Separate login + store list (when you need full store detail)

Use this only when you need fields available on ManageableStore but not on MerchantLoginStore (store_key, logo):

await client.merchant.auth.loginWithEmail({ email, password })
const stores = await client.merchant.auth.listStores() // ManageableStore[]
const storeId = stores[0].id // .id and .name match MerchantLoginStore

Example: Merchant Store Setup

const client = createClient()

await client.merchant.auth.loginWithEmail({
  email: '[email protected]',
  password: process.env.MERCHANT_PASSWORD,
})

const stores = await client.merchant.auth.listStores()
const storeId = stores[0].id

// Create a markdown article
await client.merchant.content.create(storeId, {
  content_type: 'markdown',
  title: 'Hello World',
  content_body: btoa('# Hello World\nFull article body here.'),
  price_cents: 500,
  visibility: 'public',
})

// Create an external reference (e.g. a Vimeo video)
await client.merchant.content.create(storeId, {
  content_type: 'external_ref',
  title: 'Intro to Machine Learning',
  content_uri: 'https://vimeo.com/987654321',
  external_identifier: 'vimeo:987654321',
  price_cents: 1500,
  visibility: 'public',
})

// Inline HTML — content_body and content_uri are mutually exclusive for 'html'
await client.merchant.content.create(storeId, {
  content_type: 'html',
  title: 'Interactive Report',
  content_body: '<h1>Q3 Results</h1><p>...</p>',
  price_cents: 900,
  visibility: 'public',
})

// Remote HTML, PDF, image, and video all require content_uri (never content_body)
await client.merchant.content.create(storeId, {
  content_type: 'pdf',
  title: 'Annual Report (PDF)',
  content_uri: 'https://cdn.example.com/reports/annual-2026.pdf',
  price_cents: 2000,
  visibility: 'public',
})

const items = await client.merchant.content.list(storeId)
// items.data — ContentListItem[]
// items.pagination — PaginationMeta

// Search by title (partial match), URI, and/or metadata
const results = await client.merchant.content.search(storeId, { title: 'intro' })
const byUri = await client.merchant.content.search(storeId, { uri: 'vimeo.com' })
const combined = await client.merchant.content.search(storeId, {
  title: 'tutorial',
  metadata: { category: 'ml' },
})

// Fetch Google OAuth client ID before the user has signed in
const { google_client_id } = await client.config.getPublic()
// google.accounts.id.initialize({ client_id: google_client_id, callback })

Full and view keys

A store API key logged in with its secret (client.auth.loginWithApiKey({ key, secret })) has full permission; with the key alone it has view. Reads work with either. These need a full key or an owner's merchant login, and a view key gets a 403 ForbiddenError:

  • merchant.content.create / update / delete (an is_author member may write only their own items)
  • merchant.users.list / invite / update / remove
  • merchant.pricingRules.create / deactivate
  • merchant.domains.add / verify / remove

Sales rows and buyers

merchant.sales.get() names the buyer only by buyer_ref (bref_v1_…), an opaque, store-scoped reference that matches merchant.buyers.list(). The buyer's user id and name are not disclosed. fees.*_cents are null until the sale completes.

const sale = await client.merchant.sales.get(storeId, saleId)
const { data: buyers } = await client.merchant.buyers.list(storeId)
const buyer = buyers.find((b) => b.buyer_ref === sale.buyer_ref)

Store invitations

merchant.users.invite() emails an invitation_token to someone without an account. Show the invitation with the public auth.getInviteInfo(), then let the invitee join through auth.signup() / auth.loginWithGoogle() with invitation_token, or, if they are already a merchant, through merchant.auth.acceptInvitation():

import { LedewireError } from '@ledewire/node'

const invite = await client.auth.getInviteInfo(token) // no auth; 404 / 409 / 410 when unusable
console.log(`Join ${invite.store_name}`)

try {
  await client.merchant.auth.loginWithEmail({ email, password })
  const membership = await client.merchant.auth.acceptInvitation({ invitation_token: token })
  console.log(`Joined ${membership.store_name} as ${membership.role}`)
} catch (err) {
  if (err instanceof LedewireError && err.type === 'invitation_not_accepted') {
    // err.details.reason: 'not_found' | 'wrong_email' | 'already_accepted' | 'already_member' | 'expired' | 'invalid'
  }
}

Example: Buyer Spend Cap & MCP API Keys

Every buyer starts with a default daily spend cap governing every wallet debit (MCP, REST, and the web payment gate). Read it or change it; it can't be removed:

const cap = await client.user.spendCap.get()
if (cap.remaining_cents !== null && cap.remaining_cents < 500) {
  console.warn(`Only ${cap.remaining_cents}c left before the cap resets at ${cap.resets_at}`)
}

await client.user.spendCap.update({ daily_spend_limit_cents: 2000 }) // raise to $20/day

MCP API keys authenticate agent requests to the Ledewire MCP server. The secret is shown once at creation — store it immediately:

const { key, secret } = await client.user.mcpKeys.create({
  label: 'my-rag-agent',
  can_search: true,
  can_purchase: true,
})
await secretsManager.put('LEDEWIRE_MCP_CREDENTIAL', `${key}:${secret}`)

const keys = await client.user.mcpKeys.list() // secrets never included
await client.user.mcpKeys.revoke(keys[0].id) // to change scopes: revoke + recreate

Payment keys are the public halves of the Ed25519 keys that sign x402 payments. A Payment key is never a login credential, and LedeWire never holds the private half, so there is no secret to store from the response — keep the private key yourself. Prove you hold it by signing ledewire-payment-key: + public_key:

import { generateKeyPairSync, sign } from 'node:crypto'

const { publicKey, privateKey } = generateKeyPairSync('ed25519')
// Unpadded base64url of the 32 raw public-key bytes (the tail of the SPKI DER)
const public_key = publicKey
  .export({ type: 'spki', format: 'der' })
  .subarray(-32)
  .toString('base64url')
const message = Buffer.from(`ledewire-payment-key:${public_key}`, 'ascii')
const proof = sign(null, message, privateKey).toString('base64url')

const key = await client.user.paymentKeys.create({ name: 'research-agent', public_key, proof })
const keys = await client.user.paymentKeys.list() // a plain array, newest first
await client.user.paymentKeys.revoke(key.id)

A Buyer holds at most 10 active Payment keys, so rotate by registering the replacement before revoking the old key. Only buyer tokens may call these routes (others get 403 Invalid Role); an invalid proof, a malformed key or a duplicate key is refused with 422. Signing payments with the key is @ledewire/x402-client's job.

Example: Company Wallets

A Company is a shared wallet that pays for its members' purchases. Each member spends under their own daily Spend cap, set by a Company admin, and never sees the Company balance: for a member, wallet.balance() returns balance_cents: null / spendable_cents: null, content.getWithAccess() returns wallet_balance_cents: null, and remaining_cents / company_name say what they may still spend today and whose wallet pays. Handle null before doing arithmetic on a balance.

// Any buyer: am I in a Company?
const wallet = await client.wallet.balance()
if (wallet.company_name !== null) {
  console.log(`${wallet.company_name} pays; ${wallet.remaining_cents}c left today`)
}
const membership = await client.company.membership.get() // NotFoundError if none

// Admin: invite someone. Nobody joins until they accept with the emailed token —
// an existing buyer via invitations.accept(), a new one via auth.signup() or
// auth.loginWithGoogle(). A token that can't be accepted refuses the signup (422,
// err.type === 'invitation_not_accepted', err.details.reason says why).
const invitation = await client.company.invitations.create({ email: '[email protected]' }) // role defaults to 'member'
// await client.company.invitations.revoke(invitation.id) // to withdraw it while still pending
await client.company.invitations.accept({ token }) // invitee, existing account
await client.auth.signup({ email, password, name, company_invitation_token: token }) // invitee, new account

// Admin: set a member's daily Spend cap (by membership id; never null).
const { data: members } = await client.company.members.list()
await client.company.members.update(members[0].id, { daily_spend_limit_cents: 5000 })

// Admin: a Machine user is an agent identity with no login, spending the Company's money.
const bot = await client.company.machineUsers.create({ name: 'research-agent' })
const { key, secret } = await client.company.machineUsers.buyerKeys.create(bot.id, {
  name: 'production',
})
const agent = createAgentClient({ key, secret }) // logs in as the Machine user on its first call
await client.company.machineUsers.mcpKeys.create(bot.id, {
  label: 'research-agent',
  scopes: ['mcp:search', 'mcp:purchase'],
})
// Its x402 Payment key: the agent holds the private half; the admin registers
// the public half with a proof built as for user.paymentKeys.create().
await client.company.machineUsers.paymentKeys.create(bot.id, {
  name: 'production',
  public_key,
  proof,
})
const { data: paymentKeys } = await client.company.machineUsers.paymentKeys.list(bot.id) // { data } envelope
await client.company.machineUsers.update(bot.id, { name: 'nightly-agent' }) // keys keep working
await client.company.machineUsers.deactivate(bot.id) // permanent; revokes every key

// Admin: read the Company wallet — the only place its balance appears.
const { balance_cents, held_cents, pending_top_up_cents } = await client.company.wallet.get()

// Admin: fund the Company wallet (card or ACH) and track unsettled top-ups.
// wallet.getPaymentStatus() covers personal top-ups only — it does not find a Company session.
await client.company.wallet.createPaymentSession({ amount_cents: 50000, currency: 'usd' })
const { data: pending } = await client.company.wallet.listPendingTopUps()

// Admin: what the Company paid for, and what each member spent.
const { data: purchases } = await client.company.purchases.list({ kind: 'bulk_acquisition' })
const { data: spend } = await client.company.spend.list({ from: '2026-09-01', to: '2026-09-30' })

Everything except membership and invitations.accept() is Company-admin only and throws ForbiddenError for a plain member. A member cannot change their own cap with user.spendCap.update() (403) — an admin sets it via company.members.update(). A Machine user cannot manage its own keys through user.apiKeys / user.mcpKeys / user.paymentKeys (403).

Example: Bulk Licensing (Acquisitions)

Buy a publication's entire catalog (or a date-bounded slice of it) in one transaction instead of purchasing works one at a time:

// 1. Browse the catalog and pull a page of work URLs. coverage_horizon says how
// far back LedeWire has swept a publication (null = not established yet).
// Narrow the catalog by Topic. Topic slugs come from `client.topics.list()`.
// To load one publication directly, use `client.publications.get(id)`.
const { data: publications } = await client.publications.list({
  bulk_licensable: true,
  topics: ['sports'],
})
const publication = publications[0]
const { works } = await client.publications.listWorks(publication.id, {
  from: '2026-01-01',
  to: '2026-01-31',
  page_kind: 'article', // optional: a filtered page may be empty before next_cursor is null
})

// 2. Submit the Selection. Quoting is asynchronous: poll for as long as the
// response carries poll_after_seconds (2–60s, sized from the Selection).
const wait = (s: number) => new Promise((r) => setTimeout(r, s * 1000))
let acquisition = await client.acquisitions.create({ urls: works.map((w) => w.url) })
while (acquisition.poll_after_seconds !== undefined) {
  await wait(acquisition.poll_after_seconds)
  acquisition = await client.acquisitions.get(acquisition.id)
}
// Changed your mind? A quote can be withdrawn — no money moves:
// await client.acquisitions.cancel(acquisition.id)

// 3. Acknowledge whatever cannot be sold — required before authorizing.
acquisition = await client.acquisitions.acknowledgeExclusions(acquisition.id)

// 4. Place the hold and start the run.
try {
  acquisition = await client.acquisitions.authorize(acquisition.id)
} catch (err) {
  if (err instanceof SpendCapReachedError) {
    // Funding the wallet will not clear this — it's a daily policy limit.
    console.error(`Spend cap reached; resets at ${err.resetsAt}.`)
    return
  }
  throw err
}

// 5. Poll until the run settles, then page through only the failures.
while (acquisition.poll_after_seconds !== undefined) {
  await wait(acquisition.poll_after_seconds)
  acquisition = await client.acquisitions.get(acquisition.id)
}
const { data: failed } = await client.acquisitions.listWorks(acquisition.id, {
  delivery_state: 'undelivered', // also: line_state, exclusion_reason
})

// 6. Stream the corpus archive to a file.
import { createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
import { Readable } from 'node:stream'

const result = await client.acquisitions.downloadCorpus(acquisition.id)
if (result.ready) {
  await pipeline(Readable.fromWeb(result.body), createWriteStream('corpus.tar.gz'))
}

// 7. Verify the signed manifest against the append-only signing-key log —
// never trust the `jwk` embedded in the manifest's own JWS header.
const manifest = await client.acquisitions.getManifest(acquisition.id)
const keyHistory = await client.acquisitions.signingKeyHistory()

Example: Buyer Content Search

Search every Store and every brokered Publication from a Buyer's server. Page with next_cursor until it is null. The request is a ContentSearchRequest (a Store's search of its own content, client.merchant.content.search(), takes a MerchantContentSearchRequest):

import type { ContentSearchRequest } from '@ledewire/node'

let cursor: string | null | undefined
do {
  const request: ContentSearchRequest = { query: 'quantum computing', cursor, limit: 20 }
  const page = await client.content.search(request)
  for (const result of page.data) {
    console.log(result.title, result.publication?.name ?? '(Store work)')
  }
  // partial_results: true means part of this page could not be searched. next_cursor
  // still points at what was missed, so sending it again asks for those results.
  cursor = page.next_cursor
} while (cursor)

Searches with a query are rate-limited to 15 a minute per Buyer. A 429 throws a RateLimitError (a LedewireError with statusCode 429).

A cursor lasts 24 hours and fits only the search it came from. One that is expired, invalid, or sent with different filters throws InvalidCursorError (statusCode 422, type: 'invalid_cursor') and nothing is searched. Drop the cursor and restart from the first page; the same cursor never succeeds on retry:

import { InvalidCursorError } from '@ledewire/node'

try {
  page = await client.content.search({ query: 'quantum computing', cursor })
} catch (err) {
  if (!(err instanceof InvalidCursorError)) throw err
  page = await client.content.search({ query: 'quantum computing' }) // restart
}

Narrow to one Publication with publication (its id, or its exact name in any case). To search SEC EDGAR filings, pass form_type, ticker or cik: only filings come back, and query can be left out to list them newest first. Each result's filing describes the filing (filer_name, ticker, cik, form_type, filing_date, period_of_report, accession_number), and is null for every other work. Filings are shown to every signed-in Buyer, up to that Buyer's limit on SEC requests; over it, the search returns what it could with partial_results: true.

// Apple's annual reports, amendments (10-K/A) included.
const { data } = await client.content.search({ ticker: 'AAPL', form_type: '10-K' })
for (const result of data) {
  console.log(result.filing?.form_type, result.filing?.filing_date, result.title)
}

Example: Purchase Licences

Every purchase carries license (a PurchaseLicense, or null when none was recorded — every Store work, and older brokered purchases; null never means "no rights"), plus content.publication (the brokered work's Publication, or null) and content.filing (the SEC filing, or null). license.type is the source's own licence type, such as ON_DEMAND_LICENSE, ON_DEMAND_FULL_USE_LICENSE or public-domain for an SEC filing.

When license.document_url is set, purchases.licenseDocument() downloads the document. It is not JSON: you get { body: ArrayBuffer, contentType }, usually application/pdf, or text/markdown for an SEC filing's terms.

import { writeFile } from 'node:fs/promises'

const purchase = await client.purchases.get(purchaseId)
console.log(purchase.license?.type, purchase.content.publication?.name)
if (purchase.license?.document_url) {
  const { body, contentType } = await client.purchases.licenseDocument(purchase.id)
  const ext = contentType.startsWith('application/pdf')
    ? 'pdf'
    : contentType.startsWith('text/markdown')
      ? 'md'
      : 'bin'
  const file = `licence.${ext}`
  await writeFile(file, Buffer.from(body))
}

Example: x402 Bazaar Discovery

Public, unauthenticated browsing of resources gated by the x402 ledewire-wallet scheme:

const { total, resources } = await client.x402.discoverResources({ limit: 20 })
for (const resource of resources) {
  console.log(resource.metadata.title ?? '(untitled)', resource.metadata.teaser)
}

Pay for a discovered resource via the x402 flow itself — see @ledewire/x402-client — not through this namespace, which never carries delivered content.

Confirm a page URL is a registered Ledewire resource before showing payment UI, and fetch the JWKS that verifies a settlement accessToken offline:

const origin = await client.x402.verifyOrigin({ url: 'https://theirsite.com/articles/slug' })
if (origin.verified) console.log(origin.title, origin.amount)

const { keys } = await client.x402.getJwks() // cache for 24h; match the token's kid

verifyOrigin answers { verified: false } for an unknown URL rather than throwing, and throws RateLimitError (with retryAfter) past 60 requests a minute per IP. getJwks throws a LedewireError with statusCode 503 and message 'JWKS unavailable' when the server has no signing key configured.

Error Handling

All SDK errors extend LedewireError — use instanceof checks on named subclasses:

import { createClient, ForbiddenError, AuthError } from '@ledewire/node'

try {
  await client.merchant.auth.loginWithGoogle({ id_token })
} catch (err) {
  if (err instanceof ForbiddenError) {
    // 403 — credentials are VALID but the account has no merchant store access.
    // This is the expected error when a personal Google account previously
    // registered as a buyer is used on the merchant login endpoint.
    // err.message → "This account does not have merchant access. Use a merchant or owner account."
    // Fix: use a dedicated merchant/owner account, or have a store owner add your account.
    console.error('Wrong account role:', err.message)
  } else if (err instanceof AuthError) {
    // 401 — wrong email/password, API key or Google token. err.message is the
    // API's message (e.g. "Invalid email or password"). A rejected login never
    // refreshes, retries, or clears a session the client already holds.
    console.error('Authentication failed:', err.message)
  }
}

| Subclass | Status | When thrown | | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------- | | AuthError | 401 | Invalid credentials on any login, expired token, failed token refresh | | ForbiddenError | 403 | Valid credentials, wrong account role (e.g. buyer on merchant endpoint) | | NotFoundError | 404 | Resource not found | | PurchaseError | 409/422 | Purchase validation failure (price mismatch, duplicate, etc.) | | SpendCapReachedError | 402 | Buyer's daily spend cap reached — purchases.create(), the x402 content gate, and acquisitions.authorize() | | RateLimitError | 429 | Too many requests, from any endpoint (content.search: 15/min per buyer); retryAfter is seconds to wait | | InvalidCursorError | 422 | content.search refused its cursor (type: 'invalid_cursor'): drop the cursor and restart the search | | LedewireError | any | Catch-all base class for all other API errors |

Every LedewireError also carries type (the API error body's machine-readable error.type, e.g. 'daily_spend_cap_reached', 'insufficient_funds') and details (any extra top-level fields on the error body). The bulk acquisition steps refuse with exclusions_unacknowledged, quote_not_ready, quote_expired, quote_in_progress, invalid_acquisition_state (with status / expected_status in details), nothing_to_hold, and run_not_started (503 — nothing was held; safe to retry). instanceof checks work even across the separately bundled copies of @ledewire/core inside @ledewire/node, @ledewire/browser, and @ledewire/x402-client — an error thrown by the x402-client is still recognized by SpendCapReachedError imported from @ledewire/node.

SpendCapReachedError — funding the wallet does not clear it:

import { SpendCapReachedError } from '@ledewire/node'

try {
  await client.purchases.create({ content_id, price_cents })
} catch (err) {
  if (err instanceof SpendCapReachedError) {
    // A daily policy limit, not a balance problem — do NOT prompt the buyer
    // to fund their wallet. It clears when the window rolls over at
    // err.resetsAt, or when the cap is raised via client.user.spendCap.update().
    console.error(
      `Spend cap reached: spent ${err.spentCents} of ${err.capCents} cents. Resets at ${err.resetsAt}.`,
    )
  }
}

Documentation

License

MIT