@hello-bill/sdk
v2.12.2
Published
Browser embed for the HelloBill onboarding flow. Vanilla TS Web Component with Shadow DOM (<hellobill-embed>) and programmatic HelloBill.init() API.
Downloads
15,776
Readme
@hello-bill/sdk
Browser embed for the HelloBill Partner API (Revision 1.13) (api_version 2026-05-10).
Vanilla TS Web Component with Shadow DOM (<hellobill-embed>) and a programmatic HelloBill.init() API.
- Single ESM file (npm) + IIFE bundle (script tag).
- Shadow DOM (open) — fully isolated from host-page CSS.
- 14-screen state machine with branching (anyvan + comparison gate on move data).
- WCAG 2.2 AA contrast, axe-clean, keyboard-navigable,
prefers-reduced-motionaware.
Install
npm install @hello-bill/sdk
# or via CDN (script tag, no build step):
# <script src="https://unpkg.com/@hello-bill/sdk/dist/index.global.js"></script>Quick start (custom element)
<hellobill-embed
endpoint="/api/hellobill"
session-data='{"customer":{"first_name":"Alice","last_name":"Tenant","email":"[email protected]"},"addresses":{"current":{"address_line_1":"12 Elm Road","city":"London","postcode":"N7 8AA"}},"move":{"in":{"move_in_date":"2026-06-01"}},"consent":{"data_sharing_accepted":true}}'>
</hellobill-embed>
<script type="module">
import '@hello-bill/sdk'; // registers <hellobill-embed>
</script>mode attribute
The embed supports two boot modes:
mode="card"(default) — boots into the compact partner-home feature card preview. The embed sizes to its content. Tapping the inner CTA unfurls the full takeover (16-screen flow).mode="full"— skips the preview and boots directly into the intro screen. Use this when the embed already lives inside a takeover (modal, drawer, dedicated route).
<hellobill-embed mode="card" ...></hellobill-embed> <!-- default: feature-card preview -->
<hellobill-embed mode="full" ...></hellobill-embed> <!-- skip card, open full flow -->The host element gets a data-mode="card|full" attribute reflecting the
current state — partners can style their wrapper accordingly. The card
state is a one-way bootstrap: the user cannot navigate back to it via
the in-flow back chevron.
Quick start (programmatic)
import { HelloBill } from '@hello-bill/sdk';
const handle = HelloBill.init({
mountTo: '#hellobill-root', // CSS selector or HTMLElement
baseEndpoint: '/api/hellobill', // your server-side SDK adapter
sessionData: { // partner-side user/property data
firstName: 'Sam',
lastName: 'Lee',
email: '[email protected]',
addressLine1: '12 Elm Road',
city: 'London',
postcode: 'N7 8AA',
moveInDate: '2026-06-01',
bedrooms: 2,
propertyType: 'flat',
},
onComplete: (result) => console.log('done', result),
onClose: (reason) => console.log('closed', reason),
onEvent: (event) => console.log('event', event),
});
// Later (or on partner-side cleanup):
// handle.destroy();embedOrigin (local development)
embedOrigin defaults to https://embed-sandbox.hellobill.app — correct for
production, where your live domain is on the embed's parent-origin allowlist.
During local development the embed iframe is served from a different origin
and your localhost page is not on the production allowlist, so the
iframe will mount but never finish initialising. Point embedOrigin at your
sandbox / local embed origin and ensure that origin's allowlist includes your
dev host:
HelloBill.init({
mountTo: '#hellobill-root',
baseEndpoint: '/api/hellobill',
sessionData,
embedOrigin: 'https://embed-sandbox.hellobill.app', // or your local embed origin
});Session data shape
interface SessionData {
// Customer identity
firstName: string;
lastName: string;
email: string;
phone?: string;
// Property address (flat camelCase)
addressLine1: string;
addressLine2?: string;
city: string;
postcode: string;
bedrooms?: number;
moveInDate?: string; // ISO date — e.g. '2026-06-01'
// Optional property hints
propertyType?: 'detached' | 'semi_detached' | 'terraced' | 'flat' | 'bungalow' | 'maisonette' | 'other';
// Previous address — triggers the comparison screen when present
previousAddress?: { addressLine1: string; city: string; postcode: string; /* ... */ };
// Move-out — triggers the AnyVan screen when present
moveOut?: { move_out_date: string; /* ... */ };
}The embed forwards this to your baseEndpoint + '/session' route; the SDK
then shapes it into the upstream POST /partner/sessions payload.
Events
The embed emits typed events through onEvent and as CustomEvents on the
<hellobill-embed> element (hb:*):
| Event | Fires when |
|----------------------|-----------------------------------------------------------|
| screen.entered | A new screen is shown. Payload: { screen: string }. |
| screen.exited | A screen advances. Payload: { screen: string }. |
| selection.changed | User toggles a product in pickers. |
| loa.fetched | LoA documents loaded. Payload: { loa_ids, checksum }. |
| loa.signed | All LoAs signed in this run. |
| customers.created | POST /customers returned 2xx. |
| error | Any embedded error reaches the user. Payload: { code, message }. |
| hb:complete | Convenience alias; also invokes onComplete. |
| hb:close | User dismissed (Esc or close button); invokes onClose. |
Branching
The state machine only renders the screens it has data for:
- AnyVan moving-help screen — shown when
move.outis present. - Previous-vs-new comparison screen — shown when
addresses.previousis present. - Service pickers — gated on which
categoriescome back from/products.
A simple move-in flow naturally collapses to ~9 screens; a full relocation expands to the 14-screen path.
Theming
Full theming reference: docs/theming.md
The embed ships with four theme presets, per-token variable overrides, and component-level CSS rules. All can be combined and switched live without re-mounting.
Theme presets
| Preset | Look |
|---|---|
| hellobill | Default. Deep-forest surface, mint accents, white text. |
| light | White surface, dark forest ink, darker mint accent. |
| dark | Identical to hellobill visually. Both names exist so system has a stable dark target. |
| system | Resolves to light or dark from prefers-color-scheme on init and live-updates on OS change. |
<!-- Custom element -->
<hellobill-embed theme="light" endpoint="/api/hellobill" session-data="{...}"></hellobill-embed>
<hellobill-embed theme="system" endpoint="/api/hellobill" session-data="{...}"></hellobill-embed>// Programmatic
const handle = HelloBill.init({
baseEndpoint: '/api/hellobill',
mountTo: '#hellobill',
sessionData,
appearance: { theme: 'light' },
});
// Live switch — re-cascades CSS vars, active flow stays mounted
handle.updateAppearance({ theme: 'dark' });Token overrides (appearance.variables)
Override individual design tokens on top of the active preset:
HelloBill.init({
appearance: {
theme: 'light',
variables: {
colorPrimary: '#5B21B6',
colorOnPrimary: '#ffffff',
colorText: '#0f172a', // secondary/placeholder/disabled tiers auto-derive from this
borderRadius: '8px',
buttonBorderRadius: '8px',
},
},
...
});Text-tier cascade: setting colorText alone is enough for a coherent text hierarchy. The three sub-tiers (colorTextSecondary, colorTextPlaceholder, colorTextDisabled) automatically derive from colorText when not explicitly set, so muted text never becomes invisible. Explicit sub-tier values always override the derivation.
See docs/theming.md for the full token reference table, component rules, what is locked, and the iframe sizing contract.
Iframe sizing
An unsized mount (no explicit CSS height) starts at a 720px default and then dynamically grows or shrinks to fit the embed's content, clamped between 480px and either window.innerHeight or your own maxHeight option:
HelloBill.init({
mountTo: '#hellobill-root',
baseEndpoint: '/api/hellobill',
sessionData,
maxHeight: 900, // optional — defaults to window.innerHeight
});If you size the mount yourself instead, the iframe fills that box at 100% and this dynamic behaviour does not apply — see docs/theming.md#iframe-sizing for the full contract.
The ceiling (window.innerHeight or maxHeight) is always the current viewport — a bare browser-window resize re-clamps the iframe even without a new hb:resize message, so the frame never exceeds 100vh. Content taller than that ceiling scrolls inside the embed rather than being clipped.
Browser support
Modern evergreen browsers — relies on native:
- Custom Elements v1
- Shadow DOM v1
- ES2020 (
globalThis, optional chaining, nullish coalescing) fetch,AbortController
No IE / no legacy Edge. No polyfills required for the supported targets.
Accessibility
- WCAG 2.2 AA contrast verified against design tokens (success colour bumped
to
#15803dfor the cashback pill in P6). - Every screen passes
axe-core(no serious / critical) via the Playwright harness ine2e/hellobill/. - All interactive primitives are keyboard-reachable; RadioCard handles
Enter / Space, ErrorMessage announces via
role="alert", signature canvas exposesrole="img"+aria-label. - Esc dismisses the embed and emits
hb:closewithreason: 'user_dismissed'. - Animations and transitions disabled under
prefers-reduced-motion: reduce.
Known limitation — session token expiry
The session token returned by POST /session is a short-lived JWT (300 s).
If the user idles past 5 minutes mid-flow, the next API call returns
401 auth.token_expired and the embed surfaces the upstream error.
A future onSessionExpired hook will let the host page refresh and re-mint —
this is a public-API change and is deferred to a dedicated ADR
("Embed session refresh"). See PUNCH_LIST.md → 🟡 Needs followup #1.
Build
bun run build # tsup → dist/index.js (ESM) + dist/index.global.js (IIFE)
bun run build:measure # build + gzip size report
bun run check:bundle # gzip size report only (assumes dist/ exists)Bundle size
Measured via bun run check:bundle (gzip level 9 against the tsup output).
| Format | File | Raw | Gzip | Budget |
| ------ | ---------------------- | -------- | -------- | -------- |
| ESM | dist/index.js | 76.07 KB | 17.13 KB | 50.00 KB |
| IIFE | dist/index.global.js | 80.98 KB | 17.61 KB | 50.00 KB |
Budgets are enforced — check:bundle exits non-zero if any artefact exceeds
50 KB gzipped. Current usage: ~34 % of budget.
Tests
bun run test # vitest — 34 / 34 passing
bun run typecheck # tsc --noEmitEnd-to-end coverage (Playwright + axe) lives in e2e/hellobill/ at the repo
root — 28 visual baselines (desktop + mobile) committed.
Test seams (opt-in)
The custom element exposes a tiny escape hatch surface ONLY when the host
page sets window.__hb_test_enabled = true BEFORE the element boots. In
production this flag is never set, so the methods below do not exist and
cannot be reached. The flag is unrelated to the build — the same bundle
ships everywhere.
When enabled, each <hellobill-embed> instance gains:
| Method | Purpose |
| --- | --- |
| __hb_test_setLoaChecksum(loaId, newChecksum) | Replace the in-memory LoA checksum so the next POST /customers trips loa.template_changed. Pass '*' as loaId to patch every LoA. |
| __hb_test_getScreen() | Current screen id ('intro', 'loa', …). |
| __hb_test_getContext() | Structured-clone of MachineContext (read-only snapshot). |
Usage from a Playwright test:
await page.evaluate(() => {
const el = document.querySelector('hellobill-embed');
el.__hb_test_setLoaChecksum('*', 'tampered_value');
});These exist for end-to-end testing of negative paths (LoA drift, mid-flow state inspection) and are documented to discourage accidental dependence in partner integrations.
License
MIT.
