@hello-bill/sdk
v2.17.2
Published
Browser embed for the HelloBill onboarding flow. Vanilla TS Web Component with Shadow DOM (<hellobill-embed>) and programmatic HelloBill.init() API.
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();onDismiss (optional — a Back on the first screen)
If your page can put the embed away — close an overlay, pop a modal, go back a
route — pass onDismiss. The embed's first screen then shows a Back button
in its bottom bar. Tapping it calls onDismiss() and emits a
journey.dismissed event through onEvent.
Unlike journey.closed, this does not end the journey: the iframe and its
session stay alive, so showing the same container again carries on where the
customer left off. Do not call handle.destroy() from onDismiss unless you
really want to discard the session.
HelloBill.init({
mountTo: '#hellobill-root',
baseEndpoint: '/api/hellobill',
sessionData,
onDismiss: () => closeMyOverlay(),
});Leave onDismiss out and the first screen has no Back button, exactly as before.
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. |
| journey.dismissed | Back tapped on the first screen (only when onDismiss was passed); invokes onDismiss. The embed stays mounted. |
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.
Session auto-resume
When sessionData carries no customer content (it is empty, or holds only
attribution such as utmSource / referralId / tenancyId), HelloBill.init()
first tries to resume the last session this browser reached ready on for the
same baseEndpoint, so a page refresh does not orphan the customer's draft:
- The session id is kept in
localStorageunderhellobill:resume:{baseEndpoint}. It is written when an embed first firesready, and cleared oncompleteandjourney.closed. - A stored id is used only within 2 hours (7200 s, the session token's lifetime) of being written; an older one is discarded.
- The resume is a
POST {baseEndpoint}/session/{id}/resume. If it fails for any reason,init()falls back to a normalPOST {baseEndpoint}/sessionand emits oneoperational.resume_failedevent ononEvent; the resume failure itself never reachesonError. If that fallback create also fails, it is reported like any other create failure:onError, anerrorevent, and a rejected handle. - Any real
sessionData(a name, email, address and so on) always creates a fresh session.
Where every init() must start a fresh session (an agent-assisted desk, say, or
middleware that does not serve the resume route), opt out with autoResume: false:
HelloBill.init({
mountTo: '#hellobill-root',
baseEndpoint: '/api/hellobill',
sessionData,
autoResume: false, // always create; never read, write or clear the resume record
});autoResume defaults to true. An explicit HelloBill.resume({ sessionId }) is
unaffected by it.
Known limitation — session token expiry
The session token returned by POST /session is a short-lived JWT (7200 s).
If the flow runs past 2 hours from when the token was minted, 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.
