Skip to content

Node SDK

@onebooks/node is a server-side toolkit covering everything on this site: calling the REST API with retries, the OAuth 2.0 helper functions (PKCE, code exchange, refresh, RFC 8693 session-token exchange), ES256 session-token verification, and webhook signature verification. ESM, TypeScript types included, Node 20 or later. One runtime dependency: jose (opens in a new tab) (JWKS fetching + ES256 verification).

import { OneBooksClient } from '@onebooks/node';
const client = new OneBooksClient({ accessToken });
const invoice = await client.get(`/invoices/${invoiceId}`);
const created = await client.post('/invoices', { customerId, lineItems }, {
idempotencyKey: crypto.randomUUID(),
});
// List endpoints page with `page`/`limit` and return a total:
const { invoices, total } = await client.get('/invoices', {
query: { status: 'SENT', page: 1, limit: 100 },
});
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 or a non-global fetch
})

Provide either accessToken (a static token) or getAccessToken (an async function called before every request — use this if you refresh tokens elsewhere, e.g. re-running token exchange or reading from a token store). The constructor requires exactly one.

The shorthands wrap client.request(method, path, options):

MethodSignature
client.get / client.delete(path, options?) — no body
client.post / client.put / client.patch(path, body?, options?) — body is sent as JSON

options accepts query (arrays are joined with commas; null/undefined values are dropped), idempotencyKey (sent as Idempotency-Key), headers and signal. A successful response resolves to its parsed JSON body, or undefined for a 204.

Retries use jittered exponential backoff, up to maxRetries times (default 3), and depend on whether a repeat could double-post:

FailureRetried for
429 Too Many RequestsEvery method — a rate-limited request was never processed. Retry-After (seconds or an HTTP date) sets the wait.
Network error (reset, DNS, timeout) or 502/503/504GET/HEAD requests, and any other method only when you set idempotencyKey — without one, a write the server may already have processed is never sent twice.
Any other 4xx or 5xxNever — the promise rejects immediately.

An idempotencyKey makes a write retryable, but only endpoints that de-duplicate on Idempotency-Key make the retry safe: POST /invoices, POST /invoices/:id/payments, POST /payments, POST /payments/allocate and POST /journal (see Integration patterns). Elsewhere the header is accepted but ignored, so don’t set a key just to get retries on a write that could be applied twice. On exhausted retries or a non-retried failure, the promise rejects with OneBooksApiError:

class OneBooksApiError extends Error {
status: number;
code?: string; // absent for class-validator array messages and /oauth/* endpoints
body: unknown; // the full parsed (or raw text) response body — read `body.params` for interpolated values
oauthError?: OAuthErrorCode; // OAuth helpers only: the OAuth `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, never .message — see Errors. Errors from the OAuth helpers carry .oauthError instead.

The API has two list styles, and the SDK helps with one of them:

  • Page/limit lists — /invoices, /customers and the other resource lists take page (from 1) and limit and return the rows plus a total, e.g. GET /invoices → { invoices, total }. Loop over pages yourself:

    const limit = 100;
    for (let page = 1; ; page++) {
    const { invoices, total } = await client.get('/invoices', {
    query: { status: 'SENT', page, limit },
    });
    for (const invoice of invoices) console.log(invoice.invoiceNumber);
    if (page * limit >= total) break;
    }
  • Cursor lists — endpoints that return { data, hasMore, nextCursor }. Today that’s the Events API, and client.events.iterate() pages it for you. client.paginate(path, query?, { cursorParam? }) is the generic async-iterator underneath it, for cursor endpoints only — pointed at a page/limit list such as /invoices it throws, because there is no data array to iterate.

await client.appData.definitions();
await client.appData.get({ resourceType: 'INVOICE', resourceId: invoiceId });
await client.appData.get({ resourceType: 'INVOICE', resourceIds: [id1, id2] }); // up to 50
await client.appData.set([
{ resourceType: 'INVOICE', resourceId: invoiceId, key: 'tracking_number', value: '1Z999AA10123456784' },
]); // up to 25 per call; value: null deletes
await client.appData.delete(valueId);

See App data for the field types, limits and the resource-scope rules these calls enforce server-side.

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
}

iterate() stops once it has caught up — to about 2 seconds ago, since the list holds back the newest events briefly. See Events API for that window, retention and delivery semantics.

Standalone functions — you need these before you have a client or token, so they’re exported separately rather than hanging 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. See Authentication for the underlying flow.

The token, revocation and introspection endpoints answer with standard OAuth error bodies — { "error": "invalid_grant", "error_description": "…" } — rather than the { statusCode, message, code } shape of the rest of the API. Every helper rejects with OneBooksApiError, the code in .oauthError and the optional description in .oauthErrorDescription; .message reads "invalid_grant: refresh token reuse detected" (just the code when there’s 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 (401) is a configuration problem — a wrong or missing secret, or an app that’s suspended or deactivated. Fix it rather than retrying: repeated failures lock the caller out for a minute.
  • The helpers don’t retry — not even a 429. Its .oauthError is temporarily_unavailable, and .retryAfter holds the Retry-After seconds.
  • A 5xx answers server_error. A proxy’s non-JSON error page still rejects with OneBooksApiError: .message is the HTTP status text, .body the raw text.
  • The SDK also reads the code from message, where these endpoints put it before 2026-09-23 — so .oauthError is set either way.

oauth.exchangeSessionToken — for embedded apps

Section titled “oauth.exchangeSessionToken — for embedded apps”

Trades a short-lived session token (from your frontend’s App Bridge call) for a normal access/refresh token pair. This grant is confidential-client only — run it on your backend, never in the browser:

const tokens = await oauth.exchangeSessionToken({
clientId: process.env.ONEBOOKS_CLIENT_ID,
clientSecret: process.env.ONEBOOKS_CLIENT_SECRET,
sessionToken, // read from the Authorization header app.fetch() attached, or however your frontend sent it
});
// tokens: { access_token, refresh_token, token_type, expires_in, scope, issued_token_type }

Exchange once per business and user, then keep the tokens. A session token is single-use at the exchange endpoint — its jti is spent on the first attempt — while App Bridge sends the same session token with every app.fetch() for up to about 50 seconds. A backend that exchanges on every request therefore gets invalid_grant from the second request on. The pattern that works:

  1. On every request, verifySessionToken() to learn the business (bid) and user (sub).
  2. If you have no stored tokens for that bid + sub, exchange this session token and store the pair.
  3. Otherwise use the stored access token, and oauth.refresh() it as it nears expiry (store the rotated refresh token each time). If the refresh fails, or the API answers 401, drop the stored pair and exchange the current session token instead.
  4. Run one refresh or exchange at a time per bid + sub, and let parallel requests wait for it — they carry the same session token, so only one exchange of it can succeed.

The starter template implements all four steps (the fourth within one process — several instances must serialize renewals through their database), and the invoice-action guide the first three. See Token exchange for the details and the RFC 8693 error cases this wraps.

Verifies an App Bridge session token server-side — ES256 signature against the live JWKS, aud === clientId, iss matches the API origin, exp/nbf within clockToleranceSec (default 30s), and the typ claim:

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 }

Throws (a jose error, e.g. JWTExpired) on any failure — this function never returns a “valid: false” value. Options: { clientId, apiBase? (default the OneBooks API), jwksUrl? (default "<apiBase origin>/.well-known/jwks.json"), clockToleranceSec? (default 30) }.

import { verifyWebhook } from '@onebooks/node';
import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks); // verify against the raw bytes, not a re-serialized body
try {
const event = verifyWebhook({
rawBody,
signatureHeader: req.headers['x-webhook-signature'],
secrets: [process.env.ONEBOOKS_WEBHOOK_SECRET], // pass [current, previous] during a rotation
});
// 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). Accepts the delivery if any provided secret matches any v1 value — pass both your current and previous secret (secrets: [current, previous], or the singular secret option for just one) while a secret rotation is in its overlap window. Throws on any verification failure and returns the parsed event on success — never a boolean, so a caller can’t accidentally skip the check.

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

App Bridge SDK — the browser-side counterpart for embedded apps.