npm.io
2.1.4 • Published 3d ago

@hello-bill/node

Licence
MIT
Version
2.1.4
Deps
0
Size
1.7 MB
Vulns
0
Weekly
1.1K

@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/node

The package is currently private: true and 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 optionally v2=<hex> during rotation).
  • Replay window: 300s by default (tolerance).
  • Key rotation: pass supportedVersions: ['v1', 'v2'] and accept either; cut over once v2 is 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_credentials OAuth grant against POST /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, plus auth.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.error envelope (502/504 depending on failure kind).

Idempotency

  • The SDK auto-generates a UUID v4 Idempotency-Key for 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": true from package.json.
  • Confirm the "files" field (currently ["src", "README.md"]) — add a dist/ if you ship compiled output.
  • Add a build step (tsup or tsc) and point "types" at the emitted .d.ts.
  • Verify all "exports" subpaths resolve post-build (., /express, /webhook, /types).
  • Choose a licence + add LICENSE file.
  • Add "repository" and "homepage" fields.
  • Seed CHANGELOG.md (recommend Keep a Changelog).
  • Run npm pack --dry-run and review the tarball contents.

License

TBD.

Keywords