@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.
Maintainers
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/nodeQuickstart
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(HTTP401) 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
429included. Its body is{ "error": "temporarily_unavailable", … }, and.retryAfterholds theRetry-Afterseconds to wait. - A
5xxanswersserver_error. A proxy's non-JSON error page still rejects withOneBooksApiError:.messageis the HTTP status text and.bodythe 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.oauthErroris 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 typecheckTests 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
