@hello-bill/node
Server-side SDK for the HelloBill Partner API (Revision 1.13) (api_version 2026-05-10).
Framework-agnostic core with an Express adapter; ships a webhook verifier and
the spec-derived TypeScript types.
- ESM only, bare TypeScript source — no runtime dependencies.
- Subpath imports:
@hello-bill/node/express,@hello-bill/node/webhook,@hello-bill/node/types. - Auto OAuth token caching, single-flight refresh, idempotency, retry with
Retry-After, per-attempt timeout.
Install
npm install @hello-bill/node
# or
bun add @hello-bill/nodeThe package is currently
private: trueand consumed via the monorepo workspace. See Pre-publish checklist below.
Quick start (Express)
import express from 'express';
import { createHellobillRouter } from '@hello-bill/node/express';
const app = express();
app.use('/api/hellobill', createHellobillRouter({
clientId: process.env.HELLOBILL_CLIENT_ID!,
clientSecret: process.env.HELLOBILL_CLIENT_SECRET!,
apiBaseUrl: process.env.HELLOBILL_API_BASE_URL!,
buildSessionPayload: async (req) => {
// Map whatever your host page posted → canonical snake_case SessionPayload.
const body = req.body as { firstName: string; lastName: string; email: string };
return {
customer: {
first_name: body.firstName,
last_name: body.lastName,
email: body.email,
},
addresses: { current: { /* ... */ } },
move: { in: { move_in_date: '2026-06-01' } },
consent: { data_sharing_accepted: true },
};
},
}));
app.listen(3000);The router applies express.json({ limit: '1mb' }) internally — do not wrap
it with another body parser.
Endpoints mounted
createHellobillRouter(opts) mounts these paths under the prefix you choose:
| Method | Path | Upstream |
|---|---|---|
| POST | /session |
POST /partner/sessions |
| GET | /products |
GET /partner/sessions/:session_id/products |
| GET | /loa |
GET /partner/sessions/:session_id/loa |
| POST | /customers |
POST /partner/sessions/:session_id/customers |
| GET | /status |
GET /partner/customers/:customer_id/status |
/products, /loa, /customers and /status require a session_token
Bearer (issued from POST /session). The SDK decodes it without verification
to extract session_id and forwards the upstream call with a fresh OAuth
access token.
Framework-agnostic core
Partners on Hono / Koa / Fastify / Nitro / raw Node can skip the Express adapter and use the core directly:
import { createHellobillHandler } from '@hello-bill/node';
const handlers = createHellobillHandler({
clientId: '…', clientSecret: '…', apiBaseUrl: '…',
buildSessionPayload: async (req) => ({ /* … */ }),
});
// Each handler takes the SDK's portable HellobillRequest / HellobillResponse.
// You write the ~15-line adapter that shapes your framework's req/res into them.
await handlers.session(req, res);Webhook verification
import express from 'express';
import { verifyWebhook, WebhookVerificationError } from '@hello-bill/node/webhook';
app.post(
'/api/hellobill/webhooks',
express.raw({ type: 'application/json' }), // ← raw bytes, not parsed JSON
(req, res) => {
try {
const event = verifyWebhook(
req.body,
req.header('x-hellobill-signature')!,
process.env.HELLOBILL_WEBHOOK_SECRET!,
{ tolerance: 300, supportedVersions: ['v1'] },
);
// event is typed as WebhookEvent — switch on event.type
res.status(204).end();
} catch (err) {
if (err instanceof WebhookVerificationError) return res.status(400).end();
throw err;
}
},
);- Algorithm: HMAC-SHA256 over
${unix_timestamp}.${raw_body}(Stripe-style). - Header format:
t=<unix>,v1=<hex>(and optionallyv2=<hex>during rotation). - Replay window: 300s by default (
tolerance). - Key rotation: pass
supportedVersions: ['v1', 'v2']and accept either; cut over oncev2is verified end-to-end. - Constant-time compare: implemented via
crypto.timingSafeEqual.
Never parse and re-stringify the body before verification — the HMAC is computed over the exact bytes the server sent.
Token management
- Automatic
client_credentialsOAuth grant againstPOST /auth/partner/token. - Cached in-process by default; refreshed at
expires_in − 30s. - Single-flight: parallel API calls during a refresh share one upstream request.
- Pluggable via
tokenStorage:
import type { TokenStorage } from '@hello-bill/node';
const redisStorage: TokenStorage = {
async get() { /* return { accessToken, expiresAt } | null */ },
async set(tok) { /* persist */ },
};
createHellobillRouter({ /* …, */ tokenStorage: redisStorage });Retry policy
Applied to every upstream call:
- Retried statuses:
429,500,502,503,504, plusauth.token_expired(refreshes the token then retries once). - Backoff: exponential with jitter; honours
Retry-After(seconds or HTTP date). - Attempts: up to 5 per call.
- Timeout: per-attempt, default 30 000 ms; override with
upstreamTimeoutMs. - Network failures: a hung / refused / aborted fetch becomes a clean
internal.errorenvelope (502/504 depending on failure kind).
Idempotency
- The SDK auto-generates a UUID v4
Idempotency-Keyfor every POST. - Override with
idempotencyKeyFactory: () => string.
Gotcha: the Partner API caches the first response keyed by Idempotency-Key
and ignores the body on subsequent requests with the same key. If you reuse
the same key for two semantically-different POSTs (e.g. two different customer
payloads), you get the first response back — not an error. Partners must
mint a distinct key per logical operation.
Types
The full spec surface is re-exported under a stable subpath:
import type {
SessionPayload,
SessionResponse,
ProductsResponse,
LoaResponse,
CustomersPayload,
CustomersResponse,
StatusResponse,
WebhookEvent,
} from '@hello-bill/node/types';Coverage
Measured with bun run test:coverage (vitest + v8). 69 / 69 tests passing.
| Area | Statements | Branches | Functions | Lines |
|---|---|---|---|---|
| All | 90.90 % | 84.95 % | 96.49 % | 94.70 % |
core/ |
91.63 % | 85.02 % | 97.43 % | 95.79 % |
express/ |
83.33 % | n/a | 90.90 % | 86.20 % |
webhook/ |
90.90 % | 89.18 % | 100.00 % | 94.11 % |
Uncovered lines are exhaustively defensive branches (e.g. unreachable
else in error envelope shaping, top-level Express error middleware that
fires only on developer mis-wiring).
Pre-publish checklist
Before the first npm publish:
- Remove
"private": truefrompackage.json. - Confirm the
"files"field (currently["src", "README.md"]) — add adist/if you ship compiled output. - Add a build step (
tsuportsc) and point"types"at the emitted.d.ts. - Verify all
"exports"subpaths resolve post-build (.,/express,/webhook,/types). - Choose a licence + add
LICENSEfile. - Add
"repository"and"homepage"fields. - Seed
CHANGELOG.md(recommend Keep a Changelog). - Run
npm pack --dry-runand review the tarball contents.
License
TBD.