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

@onebooks/node

v0.1.0

Published

Server-side SDK for the OneBooks API: authenticated requests with retries, OAuth helpers (PKCE, code exchange, refresh, session-token exchange), ES256 session-token verification and webhook signature verification.

Readme

@onebooks/node

Server-side SDK for the OneBooks API: an authenticated HTTP client with sane retries, OAuth 2.0 helpers (PKCE, code exchange, refresh, RFC 8693 session-token exchange), ES256 App Bridge session-token verification (via JWKS), and webhook signature verification.

ESM, TypeScript, Node ≥ 20. One runtime dependency: jose (JWKS fetching + ES256 verification).

Install

npm install @onebooks/node

Quickstart

import { OneBooksClient } from '@onebooks/node';

const client = new OneBooksClient({ accessToken: process.env.ONEBOOKS_ACCESS_TOKEN! });

const invoice = await client.get(`/invoices/${invoiceId}`);

// A write the API de-duplicates by Idempotency-Key — and that the SDK may
// therefore safely retry after a dropped connection (see "Retries").
const created = await client.post('/invoices', { customerId, lineItems }, {
  idempotencyKey: crypto.randomUUID(),
});

// List endpoints are page-based and return their own shape:
const { invoices, total } = await client.get<{ invoices: unknown[]; total: number }>('/invoices', {
  query: { page: 1, limit: 50 },
});

// The event log is cursor-paginated — iterate it without managing cursors:
for await (const event of client.events.iterate({ types: ['invoice.paid'] })) {
  console.log(event.type, event.resourceId);
}

OneBooksClient

new OneBooksClient({
  accessToken?: string;
  getAccessToken?: () => string | Promise<string>;
  apiBase?: string;      // default "https://api.getonebooks.com"
  apiVersion?: string;   // sent as the "OneBooks-Version" header
  maxRetries?: number;   // default 3
  fetch?: typeof fetch;  // override for tests / custom runtimes
})

Provide either accessToken (a static token) or getAccessToken (called before every request — use this if you refresh tokens elsewhere, a token store, per-installation tokens, etc). Exactly one is required; the constructor throws otherwise.

request(method, path, options?)

client.request<T>(method: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE', path: string, options?: {
  query?: Record<string, string | number | boolean | Array<string|number> | null | undefined>;
  body?: unknown;
  idempotencyKey?: string;
  headers?: Record<string, string>;
  signal?: AbortSignal;
}): Promise<T>

Also exposed as client.get(path, options?), client.delete(path, options?) and client.post/put/patch(path, body?, options?).

Retries

Up to maxRetries (default 3) extra attempts, by this rule:

| Failure | Retried for | |---|---| | 429 Too Many Requests | Every method. A rate-limited request was never processed, so replaying it is always safe. Honors Retry-After (seconds or an HTTP date). | | Network error (connection reset, timeout, DNS), 502, 503, 504 | GET/HEAD only — or any method when you pass idempotencyKey. Jittered exponential backoff. | | Anything else (400, 401, 403, 404, 409, 422, 500, …) | Never. |

Why writes are different: after a network error or a gateway 5xx the API may already have processed the request — only the response was lost. Blindly re-sending a POST /customers would create a second customer. With an idempotencyKey the SDK re-sends the same Idempotency-Key header, and the API returns the original result instead of acting twice.

Only these endpoints de-duplicate on Idempotency-Key today: POST /invoices, POST /invoices/:id/payments, POST /payments, POST /payments/allocate and POST /journal. Elsewhere the header is accepted but ignored — setting a key there makes the SDK retry, yet the server cannot de-duplicate, so only set one where it is honored (or where a duplicate is harmless). A write without a key fails fast instead: catch the error and reconcile (for example, look the record up) before trying again.

On exhausting retries, or on any non-retried failure, the promise rejects with OneBooksApiError (a network error is re-thrown as-is):

class OneBooksApiError extends Error {
  status: number;
  code?: string;                  // machine-readable code; absent for class-validator array messages and OAuth endpoints
  body: unknown;                  // the full parsed (or raw text) response body
  oauthError?: OAuthErrorCode;    // OAuth helpers only: the RFC 6749 `error` code, e.g. "invalid_grant"
  oauthErrorDescription?: string; // OAuth helpers only: `error_description`, when sent
  retryAfter?: number;            // seconds, from the response's Retry-After header (every 429 has one)
}

Branch on .code, not .message — see the errors doc. Errors from the OAuth helpers carry .oauthError instead.

paginate(path, query?, options?)

for await (const event of client.paginate<OneBooksEventEnvelope>('/events', { types: 'invoice.paid' }, { cursorParam: 'after' })) { ... }

Async-iterates every item of a cursor-paginated endpoint — one that returns { data, hasMore, nextCursor }. options.cursorParam names the query parameter the endpoint reads the cursor from (default "cursor").

Today the only cursor-paginated partner endpoint is GET /events (cursor parameter after) — prefer client.events.iterate(), which wraps exactly this. Resource lists such as GET /invoices are page-based and return their own shape ({ invoices, total }, with page/limit query parameters), so paginate() does not apply to them; page them with client.get().

appData — Contract 6 partner app-data API

await client.appData.definitions();                    // -> AppDataDefinition[]
await client.appData.get({ resourceType: 'INVOICE', resourceId: 'inv_1' });      // -> AppDataValue[]
await client.appData.get({ resourceType: 'INVOICE', resourceIds: ['a', 'b'] });  // -> AppDataValue[]
await client.appData.set([{ resourceType: 'INVOICE', resourceId: 'inv_1', key: 'tracking_number', value: '1Z...' }]); // up to 25; value: null deletes
await client.appData.delete(valueId);

events — Contract 6 partner events API

const page = await client.events.list({ types: ['invoice.paid'], limit: 50 });
const event = await client.events.get(eventId);

for await (const event of client.events.iterate({ after: lastSeenEventId })) {
  // process, then persist event.id as your new "after" cursor
}

OAuth helpers (oauth)

Standalone functions — you need these before you have a client/token, so they don't hang off OneBooksClient:

import { oauth } from '@onebooks/node';

const { codeVerifier, codeChallenge } = await oauth.createPkcePair();

const authorizeUrl = oauth.buildAuthorizeUrl({
  clientId, redirectUri, scopes: ['invoices:read'], state, codeChallenge,
});
// -> redirect the user's browser here

// ... on your callback route:
const tokens = await oauth.exchangeCode({
  clientId, clientSecret /* confidential only */, code, redirectUri, codeVerifier,
});

const refreshed = await oauth.refresh({ clientId, clientSecret, refreshToken: tokens.refresh_token! });

await oauth.revoke({ clientId, clientSecret, token: tokens.refresh_token!, tokenTypeHint: 'refresh_token' });

const introspection = await oauth.introspect({ clientId, clientSecret, token: tokens.access_token });

A public (PKCE) client omits clientSecret everywhere above — the SDK sends client_id in the request body instead of an Authorization: Basic header, matching the authentication doc.

Errors from the OAuth helpers

/oauth/token, /oauth/revoke and /oauth/introspect answer errors the standard OAuth way (RFC 6749 §5.2, RFC 7009, RFC 7662), not in the OneBooks { statusCode, message, code } shape the rest of the API uses:

{ "error": "invalid_grant", "error_description": "refresh token reuse detected" }

Every helper rejects with OneBooksApiError, with the code in .oauthError and the optional description in .oauthErrorDescription. .message reads "invalid_grant: refresh token reuse detected" (just the code when there is no description), and .code stays unset. Branch on .oauthError:

import { OneBooksApiError, oauth } from '@onebooks/node';

try {
  tokens = await oauth.refresh({ clientId, clientSecret, refreshToken });
} catch (err) {
  if (err instanceof OneBooksApiError && err.oauthError === 'invalid_grant') {
    // expired, revoked, reused, or the business uninstalled you: re-authorize
  } else if (err instanceof OneBooksApiError && err.status === 429) {
    // rate limited: wait err.retryAfter seconds before the next token request
  } else {
    throw err;
  }
}
  • invalid_client (HTTP 401) is a configuration problem — a wrong or missing secret, or an app that is suspended or deactivated. Fix it rather than retrying: repeated failures lock the caller out for a minute.
  • The helpers don't retry, a 429 included. Its body is { "error": "temporarily_unavailable", … }, and .retryAfter holds the Retry-After seconds to wait.
  • A 5xx answers server_error. A proxy's non-JSON error page still rejects with OneBooksApiError: .message is the HTTP status text and .body the raw text.
  • The helpers also read the code from message, where these endpoints put it before 2026-09-23 ({ "statusCode": 400, "message": "invalid_grant: expired", … }), so .oauthError is filled against an older server too.

exchangeSessionToken — Contract 3 (embedded apps)

For an app embedded via App Bridge: trade the short-lived session token (app.sessionToken() on the frontend) for a normal access/refresh token pair scoped to that installation. Confidential clients only.

const tokens = await oauth.exchangeSessionToken({
  clientId: process.env.ONEBOOKS_CLIENT_ID!,
  clientSecret: process.env.ONEBOOKS_CLIENT_SECRET!,
  sessionToken, // from your frontend, e.g. a header your embedded page sent you
});

Exchange once, cache the pair; verify — don't exchange — on every request. Each session token can be exchanged exactly once: the token endpoint records its jti, and a second exchange of the same token fails with invalid_grant. App Bridge deliberately reuses one session token for up to ~50 seconds across all of a page's app.fetch() calls, so a backend that exchanges on every request breaks on its second request. The pattern:

const claims = await verifySessionToken(sessionToken, { clientId });   // every request
const key = `${claims.bid}:${claims.sub}`;
let pair = await store.get(key);
if (!pair) {
  pair = await oauth.exchangeSessionToken({ clientId, clientSecret, sessionToken }); // once
  await store.set(key, pair);
} else if (expiresSoon(pair)) {
  pair = await oauth.refresh({ clientId, clientSecret, refreshToken: pair.refresh_token! });
  await store.set(key, pair);
}
// If the refresh fails (tokens revoked — e.g. uninstall, suspension) or the
// API answers 401, drop the stored pair and exchange a FRESH session token.

Run that exchange/refresh one at a time per key: a page's parallel requests carry the same session token, so when two of them find no stored pair and both exchange it, the second gets invalid_grant. Let concurrent requests wait for the exchange or refresh already in flight.

The starter template (developer-sdk/templates/embedded-node/lib/session-tokens.mjs, wired up in lib/onebooks.mjs) implements exactly this, including the fallback and the one-at-a-time rule.

verifySessionToken(token, options)

Verifies an App Bridge session token server-side (ES256, JWKS from GET /.well-known/jwks.json) — use this in every backend route your embedded app calls, to authenticate the (business, user) making the request (verification is local and repeatable; only the exchange is single-use):

import { verifySessionToken } from '@onebooks/node';

const claims = await verifySessionToken(token, { clientId: process.env.ONEBOOKS_CLIENT_ID! });
// claims: { iss, aud, sub (userId), bid (businessId), role, locale, jti, iat, nbf, exp, typ }

Checks performed: ES256 signature against the live JWKS (fetched once, cached, and refreshed automatically by jose on a kid it hasn't seen), aud === clientId, iss === apiBase's origin, exp/nbf within clockToleranceSec (default 30s), and the typ claim equals "onebooks.app_session" — a token minted for some other purpose but sharing the same signing keys must not be accepted here. Throws (a jose error, e.g. JWTExpired) on any failure.

Options: { clientId, apiBase? (default the OneBooks API), jwksUrl? (default "<apiBase origin>/.well-known/jwks.json"), clockToleranceSec? (default 30) }.

verifyWebhook(options)

import { verifyWebhook } from '@onebooks/node';
import { createServer } from 'node:http';

const server = createServer(async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  const rawBody = Buffer.concat(chunks); // MUST verify against the raw bytes, not a parsed/re-serialized body

  try {
    const event = verifyWebhook({
      rawBody,
      signatureHeader: req.headers['x-webhook-signature'] as string,
      secrets: [process.env.ONEBOOKS_WEBHOOK_SECRET!], // pass [new, old] while rotating
    });
    // event: { id, eventId, type, apiVersion, businessId, created, data }
    res.writeHead(200).end();
    void handle(event);
  } catch {
    res.writeHead(400).end();
  }
});

Verifies X-Webhook-Signature: t=<unix>,v1=<hex>[,v1=<hex>] — HMAC-SHA256 of `${t}.${rawBody}` — with a constant-time comparison, and rejects a timestamp older than toleranceSec (default 300) to blunt replay of a captured delivery. Accepts the delivery if ANY provided secret matches ANY v1 value, so passing both your current and previous secret during a rotation window (secrets: [current, previous]) verifies deliveries signed with either. Throws on any failure; on success, returns the parsed event — never a boolean, so a caller can't accidentally ignore a failed check.

parseWebhookEvent(rawBody) parses the envelope without verifying anything — only use it on a body you already ran through verifyWebhook().

Development

npm install
npm run build       # tsc -> dist/
npm test            # node:test via tsx — no network, no real timers beyond real retry backoff
npm run typecheck

Tests cover: webhook verification (valid / tampered / stale timestamp / secret rotation with two v1 values), session-token verification against a locally generated ES256 key and a mocked JWKS fetch (aud mismatch, expired, wrong typ, unknown signing key, clock tolerance) — no real network call — the client's retry rule against a fake fetch (including that a write without an idempotency key is never replayed), and the OAuth helpers' error parsing for both the RFC 6749 body and an older server's message body, Retry-After on a 429, and a non-JSON gateway page.

License

MIT