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).
Install
Section titled “Install”The client
Section titled “The client”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):
| Method | Signature |
|---|---|
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
Section titled “Retries”Retries use jittered exponential backoff, up to maxRetries times (default
3), and depend on whether a repeat could double-post:
| Failure | Retried for |
|---|---|
429 Too Many Requests | Every 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/504 | GET/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 5xx | Never — 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.
Pagination
Section titled “Pagination”The API has two list styles, and the SDK helps with one of them:
-
Page/limit lists —
/invoices,/customersand the other resource lists takepage(from 1) andlimitand return the rows plus atotal, 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, andclient.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/invoicesit throws, because there is nodataarray to iterate.
App data (client.appData)
Section titled “App data (client.appData)”await client.appData.definitions();await client.appData.get({ resourceType: 'INVOICE', resourceId: invoiceId });await client.appData.get({ resourceType: 'INVOICE', resourceIds: [id1, id2] }); // up to 50await client.appData.set([ { resourceType: 'INVOICE', resourceId: invoiceId, key: 'tracking_number', value: '1Z999AA10123456784' },]); // up to 25 per call; value: null deletesawait client.appData.delete(valueId);See App data for the field types, limits and the resource-scope rules these calls enforce server-side.
Events (client.events)
Section titled “Events (client.events)”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.
OAuth helpers (oauth)
Section titled “OAuth helpers (oauth)”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.
Errors from the OAuth helpers
Section titled “Errors from the OAuth helpers”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.oauthErroristemporarily_unavailable, and.retryAfterholds theRetry-Afterseconds. - A
5xxanswersserver_error. A proxy’s non-JSON error page still rejects withOneBooksApiError:.messageis the HTTP status text,.bodythe raw text. - The SDK also reads the code from
message, where these endpoints put it before 2026-09-23 — so.oauthErroris 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:
- On every request,
verifySessionToken()to learn the business (bid) and user (sub). - If you have no stored tokens for that
bid+sub, exchange this session token and store the pair. - 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 answers401, drop the stored pair and exchange the current session token instead. - 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.
verifySessionToken(token, options)
Section titled “verifySessionToken(token, options)”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) }.
verifyWebhook(options)
Section titled “verifyWebhook(options)”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().
Where next
Section titled “Where next”App Bridge SDK — the browser-side counterpart for embedded apps.