HelloBill Partner Integration · Embed SDK
Revision 1.22 Versioning

Embed SDK Integration Guide

Drop the HelloBill onboarding flow into your product with a one-line frontend mount and a one-line backend middleware. Your customers move into their new home, you ship a finished tenant experience — we handle utility setup, switching, and post‑handoff billing.

Backend setup
1 line
Mount the middleware
Frontend setup
1 line
Init the embed
Customer journey
~10 screens
~2 min with full data
Session expiry
30 days
Access token: 5 min · Session token: 7200 s

This guide covers the Embed SDK integration: how to ship a HelloBill-powered onboarding experience inside your product. Your server mounts a middleware that exchanges a session payload for a token; your frontend mounts the embed; HelloBill handles the rest.

Base URL:

EnvironmentPartner API base
Productionhttps://partnerapi.hellobill.app/api/v1

Mode (sandbox vs production) is determined by the client_id prefix (sb_ or live_) carried in the access token — not by the host. See Sandbox vs production.

Why the middleware pattern?

The security model keeps API credentials off the browser entirely. The @hello-bill/node package mounts the routes the embed needs (session creation, LoA capture, status polling) under a single base path on your server. You configure one mount; everything else is handled.

Quick start #

The minimum viable integration: 60 seconds, one server file, one client file. You'll need client_id + client_secret from PartnerDock (sandbox keys are prefixed sb_).

1. Install

bash
npm install @hello-bill/node @hello-bill/sdk

2. Mount the middleware (server)

typescript
import express from 'express';
import { createHellobillRouter } from '@hello-bill/node/express';

const app = express();
app.use(express.json());

app.use('/api/hellobill', createHellobillRouter({
  clientId:     process.env.HELLOBILL_CLIENT_ID,    // sb_… or live_…
  clientSecret: process.env.HELLOBILL_CLIENT_SECRET,

  buildSessionPayload: async (req) => ({
    customer:  { type: 'tenant', email: req.body.email },
    addresses: {
      current: {
        address_line_1: req.body.addressLine1,
        city:           req.body.city,
        postcode:       req.body.postcode,
      },
      // Optional: only when the customer is moving OUT of a previous property
      previous: req.body.previousAddress ?? undefined,
    },
    move: {
      in:  { move_in_date: req.body.moveInDate },
      // Optional: when partner wants HelloBill to notify incumbents at the previous address
      out: req.body.moveOut ?? undefined,
    },
    consent: { data_sharing_accepted: true, data_sharing_accepted_at: new Date().toISOString() },
  }),
}));

3. Mount the embed (client)

html
<script src="https://embed-sandbox.hellobill.app/v1/sdk.js"></script>
<div id="hellobill-mount"></div>
<script>
  HelloBill.init({
    baseEndpoint: '/api/hellobill',
    mountTo: '#hellobill-mount',
    sessionData: {
      email: currentCustomer.email,
      addressLine1: property.addressLine1,
      addressLine3: property.addressLine3,        // NEW v11 — optional 3rd line
      city:         property.city,
      postcode:     property.postcode,
      moveInDate:   occupancy.moveInDate,
      // Optional B2B: customer's billing address differs from property
      customerAddress: currentCustomer.address,
      // Optional move-out journey
      previousAddress: previousProperty,           // PropertyObject; null when no move-out
      moveOut: undefined,                          // MoveOutObject; omit for move-in only
    },
    onComplete(result) {
      window.location.href = '/move/complete?account=' + result.customer_id;
    },
  });
</script>
How the SDK composes the embed URL

The API returns embed_base_url — the embed origin only (e.g. https://embed-sandbox.hellobill.app), with no path or token. The SDK validates this origin against its compiled allowlist and resolves the tier (auto-detected from this origin unless pinned — see § Tier selection), then composes the iframe URL as {embed_base_url}/{sdk_version}/onboard?token={session_token}. The version segment comes from the pinned SDK build; the origin comes from the API. Do not construct the iframe URL yourself — always use the SDK.

No-JS direct-link alias /v1/onboard (1.21)

Alongside the versioned path, the embed origin serves a stable mutable alias at {embed_base_url}/v1/onboard — the direct-link (no-JS) entry point for partners who link to the flow without loading the SDK. On dev/UAT this alias is overwritten in place each release; on prod the version-pinned path is served alongside it. Use the SDK-composed versioned URL for embedded integrations; /v1/onboard is the stable fallback for the no-JS path.

That's it

The customer sees the HelloBill onboarding flow, picks their products, signs the Letter of Authority, and you get a customer_id back in onComplete — plus webhook events for what happens after handoff. On the sandbox tier that is a full simulated stream; on live, note the delivery gap at 8fca443c8 (rows are enqueued but not dispatched) — see the per-tier emission & delivery callout.

API credentials #

Provisioned via PartnerDock → API CredentialsCreate Key Pair:

  • client_id — prefixed sb_ (sandbox) or live_ (production), e.g. sb_goodlord_abc123.
  • client_secret — treat as a password. Never expose in browser bundles or client source.

When using the Embed SDK with the middleware pattern, you never write auth code. createHellobillRouter / createHellobillHandler handle token exchange, caching, and refresh internally. The endpoint and error codes below are reference only.

Token exchange

POST /api/v1/auth/partner/token

http
POST /api/v1/auth/partner/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=sb_goodlord_abc&client_secret=***

Response:

json
{
  "access_token": "eyJhbGciOiJSUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 300
}
  • Cache the token. Refresh at expires_in − 30 s.
  • The @hello-bill/node SDK handles caching and refresh automatically.
  • Re-authenticate and retry once on any 401 — the API does not distinguish wrong credentials from expired or malformed tokens.

Auth error codes

HTTPCodeRetryableCause
400validation.failednoMissing or invalid grant_type, client_id, or client_secret in the request body
401auth.invalid_credentialsno (re-auth)Keycloak rejected the credentials (wrong client_id/client_secret), or a /partner/* request presented a missing, malformed, or expired Bearer token
403auth.insufficient_scopeno(target) Key authenticated but lacks permission for the resource
429rate.limitedyes(target) Token endpoint rate limit — see Rate limits
502internal.erroryesKeycloak unreachable, or returned a non-token response
Token validation is uniform

A wrong secret, a malformed JWT, and an expired JWT all surface as 401 auth.invalid_credentials on /partner/* routes — the API does not distinguish between them in the error code, to keep the auth boundary opaque.

Conventions #

These conventions apply throughout the API and you can rely on them when writing parsers, validators, and SDKs:

SurfaceConventionExample
JSON body field namessnake_casecustomer.first_name, data_sharing_accepted
Query parameterssnake_case?force_refresh=true&product_ids=…
HTTP header namesKebab-Case-Title (RFC 7230)Authorization, Idempotency-Key, X-HelloBill-Signature
Path segmentslowercase, hyphenated/partner/sessions/:id/products
Enum string valuessnake_case"dual_fuel", "owner_occupier"
TypeScript SDK fieldscamelCaseclient.sessions.create({ … }) — language-idiomatic
Time valuesISO 8601 UTC"2026-05-10T14:32:00Z"
Date valuesISO 8601 date"2026-05-10"
Monetary valuesInteger pence, GBPmonthly_amount_pence: 16724

Standard REST/JSON conventions throughout. No camelCase in JSON bodies; no snake_case in SDK identifiers.

Resource scoping #

All /partner/* endpoints are scoped to the partner_org_id derived from the access_token. Cross-partner access is impossible:

  • A request for a resource (session, customer, LoA) owned by a different partner returns 404 not_found (not 403), to prevent existence enumeration.
  • IDs returned by the API (session_id, customer_id, loa_id, product_id, setup_id) are opaque to partners; do not parse or correlate them.
  • Sandbox and production are separate scope realms — see Sandbox & testing.

Sandbox vs production #

client_id prefixModeSide effects
sb_SandboxMock discovery, no real emails, no real switches
live_ProductionReal discovery, real emails, real switches

Session creation #

POST /api/v1/partner/sessions

The SDK calls this endpoint from within your middleware to initiate a session. Triggers async product discovery and returns a session_token for the embed plus a session_id for subsequent reference.

As an Embed SDK partner, you do not call this endpoint directly — the middleware does it. But you do define the buildSessionPayload function that produces the request body, so this section is your contract.

Request headers

HeaderRequiredNotes
AuthorizationrequiredBearer {access_token} — handled by SDK
Content-Typerequiredapplication/json — handled by SDK
Idempotency-KeyrecommendedUUID v4. Safe to retry on network failure.

Request payload #

The full shape you return from buildSessionPayload:

json
{
  "referral_id": "REF-2026-05-10-abc123",

  "customer": {
    "type": "tenant",
    "title": "ms",
    "first_name": "Anya",
    "last_name": "Haq",
    "full_legal_name": "Anya Haq",
    "email": "anya@example.com",
    "phone": "+447700900000",
    "date_of_birth": "1995-03-15",
    "is_primary_occupant": true,
    "on_psr": false,
    "address": {
      "address_line_1": "Goodlord Lettings",
      "address_line_2": "Unit 4, Lensbury Way",
      "address_line_3": "Thamesmead",
      "city": "London",
      "postcode": "SE2 9TG"
    },
    "credit_check": {
      "previous_addresses": [
        { "address_line_1": "Holly Cottage", "city": "Tadcaster", "postcode": "LS24 9PX", "move_in_date": "2023-02-01" },
        { "address_line_1": "27 Beech Avenue", "city": "Leeds", "postcode": "LS6 2AT", "move_in_date": "2020-08-15", "move_out_date": "2023-01-31" }
      ]
    }
  },

  "occupants": [
    {
      "type": "tenant",
      "title": "mr",
      "first_name": "James",
      "last_name": "Haq",
      "full_legal_name": "James Haq",
      "email": "james@example.com",
      "phone": "+447700900001",
      "is_primary_occupant": false
    }
  ],

  "addresses": {
    "current": {
      "address_line_1": "14 Clapham Road",
      "address_line_2": "Flat 3",
      "address_line_3": null,
      "city": "London",
      "postcode": "SW8 2JB",
      "uprn": "100023456789",
      "bedrooms": 2,
      "number_occupants": 2,
      "property_type": "flat",
      "epc_rating": "C",
      "heating_type": "gas_central"
    },
    "previous": {
      "address_line_1": "Holly Cottage",
      "address_line_2": "Mill Lane",
      "address_line_3": "Saxton-cum-Scarthingwell",
      "city": "Tadcaster",
      "postcode": "LS24 9PX",
      "uprn": "100022345678",
      "property_type": "house"
    }
  },

  "move": {
    "in": {
      "move_in_date": "2026-06-01",
      "bill_type": "single",
      "rent_pcm": 1450,
      "tenancy_start_date": "2026-06-01",
      "tenancy_end_date": "2027-05-31"
    },
    "out": {
      "move_out_date": "2026-05-30",
      "tenancy_end_date": "2026-05-31",
      "reason": "end_of_tenancy",
      "forwarding_address": {
        "address_line_1": "14 Clapham Road",
        "address_line_2": "Flat 3",
        "city": "London",
        "postcode": "SW8 2JB"
      },
      "notify": {
        "energy":      true,
        "water":       true,
        "council_tax": true,
        "tv_licence":  false,
        "broadband":   true,
        "mobile":      false
      },
      "final_reads": [
        {
          "category": "energy",
          "fuel": "electricity",
          "meter_serial_number": "S95L00012345",
          "reading": 42180,
          "read_date": "2026-05-30"
        },
        {
          "category": "energy",
          "fuel": "gas",
          "meter_serial_number": "G4A00098765",
          "reading": 9817,
          "read_date": "2026-05-30"
        }
      ]
    }
  },

  "meters": {
    "mpan": "1900000123456",
    "mprn": "7612345678",
    "current_supplier_electricity": "British Gas",
    "current_supplier_gas": "British Gas"
  },

  "consent": {
    "data_sharing_accepted": true,
    "marketing_accepted": false,
    "data_sharing_accepted_at": "2026-05-10T14:30:00Z"
  },

  "context": [
    { "name": "device_type",    "type": "string",  "value": "iPhone 16 Pro" },
    { "name": "utm_source",     "type": "string",  "value": "email" },
    { "name": "utm_campaign",   "type": "string",  "value": "move_in_2026_q2" },
    { "name": "gl_tenancy_id",  "type": "string",  "value": "TEN-89234" },
    { "name": "session_seconds","type": "number",  "value": 42 }
  ]
}
Minimum required

customer.email · addresses.current.postcode · addresses.current.address_line_1 · consent.data_sharing_accepted: true. Everything else skips screens in the adaptive embed flow — richer payload, shorter customer journey.

Move-out is optional

Partners who only need move-in support can omit addresses.previous and move.out entirely. If move.out is present, addresses.previous is required (the inverse is allowed — partners may supply addresses.previous alone to capture the previous address for the record, with no supplier notifications until move.out is added).

customer.address is optional

When omitted, the server defaults the customer's billing/contact address to addresses.current. Supply explicitly when the customer's contact address differs from the property the utilities serve — common for property-management companies acting on behalf of tenants, lettings agents, or any B2B integration where the account holder is not the occupant.

customer required #

The account holder. Typically the primary occupant of the property; for B2B integrations, the account holder may differ from the occupant — see customer.address.

FieldTypeRequiredDefaultValidationNotes
typeenumrequiredCustomerTypeLegal relationship to property
titleenumrequired 1.19TitleSalutation. See Title enum below.
other_titlestringconditional 1.19nullMax 50 charsRequired iff title === 'other'. Free-text (e.g. "Prof", "Rev"). Ignored otherwise.
emailstringrequiredRFC 5322, max 254 charsAccount creation; magic link sent after bank details (1.21)
first_namestringconditional 1.19nullMax 100 charsRequired for all type values except management_company. Skips name screen.
last_namestringoptionalnullMax 100 charsSkips name screen
full_legal_namestringconditional 1.19nullMax 200 charsRequired for management_company (the company's legal name). Highly recommended for all other types. May be a company name.
phonestringoptionalnullE.164 formatSMS auth fallback
date_of_birthstringoptionalnullISO 8601 YYYY-MM-DDRequired by some energy suppliers
is_primary_occupantbooleanoptionaltrueFalse if creating on behalf of another
on_psrbooleanoptionalnullPriority Services Register status
addressAddressObjectoptionaladdresses.currentSee AddressObjectCustomer's billing/contact address. Omit to default to the current property. Supply explicitly for B2B integrations where the account holder's address differs from the property.
credit_checkCreditCheckObjectoptional 1.19nullSee belowResidence history for credit-checked energy products. Collected by the embed after product selection if not supplied. See § Credit-check addresses.

Title enum 1.19mr · mrs · miss · ms · mx · dr · other. When other, supply other_title free-text. The minimal-but-lossless set: it maps cleanly down to any supplier's salutation list (suppliers carry larger lists, never smaller), without conflating distinct titles (e.g. Mrs vs Miss), and other + other_title captures the long tail (Prof, Rev, Sir, Dame).

Name fields and customer.type (1.19). first_name is required for every type except management_company (a managing agent is an organisation, not a named individual — its identity is carried by full_legal_name). full_legal_name is required for management_company and recommended for all other types. The embed branches its name screen accordingly: a management_company customer is asked for a single Legal / company name field rather than first/last.

CreditCheckObject 1.19 #

Residence history for credit-checked products (currently energy, e.g. OVO). This is identity / underwriting data and is deliberately separate from addresses.previous + move.out (which model operational move-out: supplier notifications, final reads, forwarding address for one departing property). Do not merge them.

FieldTypeRequiredValidationNotes
previous_addressesCreditCheckAddress[]optionalMax 12 entriesOrdered residence history, most recent first.

CreditCheckAddress extends AddressObject (address lines, city, postcode, uprn) plus: move_in_date (required, ISO date, ≤ today) and move_out_date (optional, ISO date, ≥ move_in_date; omit = current residence — exactly one entry may omit it). It does not carry PropertyObject characteristics (bedrooms, EPC, heating) — those are survey data, not underwriting data.

move_in_date vs move.in.tenancy_start_date: different concepts. tenancy_start_date is the tenancy contract date for the property being onboarded now; credit_check move_in_date is a residence-occupancy date for a historical address. Do not conflate them.

CustomerType enum — exhaustive:

ValueWhen to use
owner_occupierOwns and occupies (freehold or long leasehold)
tenantRenting under tenancy agreement (AST, periodic, regulated)
licenseeLicence to occupy — legally distinct from a tenancy
lodgerLodger or sub-tenant in another person's home
management_companyProfessional managing agent acting on behalf of owner
otherAny relationship not covered above

occupants optional, array #

Additional people living at the property. May be empty [] or omitted.

FieldTypeRequiredDefaultValidation
typeenumrequiredOccupantType
titleenumoptional 1.19nullTitle (same enum as customer.title)
other_titlestringconditional 1.19nullRequired iff title === 'other'; max 50 chars
first_namestringoptionalnullMax 100 chars
last_namestringoptionalnullMax 100 chars
full_legal_namestringoptional 1.19nullMax 200 chars; may be a company name
emailstringoptionalnullRFC 5322
phonestringoptionalnullE.164
is_primary_occupantbooleanoptionalfalseAt most one true per array

OccupantType enum: owner · tenant · licensee · lodger · guarantor · other

Occupant name fields are all optional — the conditional requiredness (§ Validation) applies only to customer, not occupants[]. occupants[].title / occupants[].full_legal_name are convenience fields for partners who already hold them.

AddressObject shared base #

Used wherever an address appears: customer.address, addresses.current, addresses.previous, move.out.forwarding_address, and (extended) customer.credit_check.previous_addresses[].

FieldTypeRequiredDefaultValidationNotes
address_line_1stringrequiredFree text, 1–255 chars (server normalises whitespace, casing, BS 7666 punctuation)First display line — usually the building name/number + street
address_line_2stringoptionalnullMax 255 charsSecond display line — usually a flat/unit reference
address_line_3stringoptionalnullMax 255 charsThird display line — rural / industrial-estate / multi-building cases (Royal Mail PAF supports up to five lines; three is sufficient for the vast majority of UK addresses)
citystringrequiredMax 100 charsPost town
postcodestringrequiredUK postcode, 3–8 chars
uprnstringoptionalnull12-digit UPRNRoyal Mail / OS Unique Property Reference. Canonical when supplied.
countryenumoptional"GB""GB" (reserved for future expansion)Currently UK addresses only

addresses required #

Grouped current property (required) + optional previous property for move-out journeys.

FieldTypeRequiredNotes
addresses.currentPropertyObjectrequiredThe property the customer is moving INTO. Where utilities are set up.
addresses.previousPropertyObjectoptionalThe property the customer is moving OUT of, if applicable. Required when move.out is present.

PropertyObject extends AddressObject with optional physical characteristics: bedrooms (1–20), number_occupants (1–50), property_type (PropertyType enum), epc_rating (AG), heating_type (HeatingType enum). For addresses.previous, partners typically supply only the address lines + postcode + UPRN.

PropertyType: detached · semi_detached · terraced · flat · bungalow · maisonette · other

HeatingType: gas_central · electric · oil · heat_pump · district · solid_fuel · other

move required #

Unified move-in (required) + optional move-out details.

move.in required

FieldTypeRequiredDefaultValidationNotes
move_in_datestringrequiredISO 8601 YYYY-MM-DDSkips move-in screen
bill_typeenumoptional"single"single | splitHow bills are apportioned
rent_pcmintegeroptionalnullPositive integer, GBPAffordability context
tenancy_start_datestringoptionalnullISO 8601Tenancy commencement (may pre-date physical move-in)
tenancy_end_datestringoptionalnullISO 8601, after tenancy_start_dateTenancy expiry

BillType: single (one person responsible) · split (each occupant pays their share — Glide, Huddle, Acasa pattern)

move.out optional

Move-out captures the customer's departure from addresses.previous. Supplying move.out requires addresses.previous to be present.

FieldTypeRequired (when move.out present)Notes
move_out_datestringrequiredISO 8601. Accepted up to 90 days in the past and 180 days in the future.
tenancy_end_datestringoptionalMay differ from physical move-out (council-tax liability often follows tenancy end)
reasonenumoptionalend_of_tenancy · sale · transfer · bereavement · other
forwarding_addressAddressObjectoptionalWhere final bills, refund cheques, and Royal Mail redirection should go
notifyobjectoptionalPer-service opt-in flags: energy, water, council_tax, tv_licence, broadband, mobile — each defaults to false. true opts the customer in to a notification with their incumbent supplier in that category at the previous address.
final_readsFinalReadObject[]optionalMax 8 entries. Final meter readings at the previous property.

FinalReadObject

FieldTypeRequiredNotes
categoryenumrequiredenergy | water
fuelenumrequired when category: 'energy'electricity | gas. Omit for water.
meter_serial_numberstringoptionalMax 50 chars
readingintegerrequiredReading value in the supplier's natural units (kWh / m³)
read_datestringrequiredISO 8601 YYYY-MM-DD, on or before move.out.move_out_date
photo_data_base64stringoptionalPNG, max 500 KB decoded. Audit evidence; supplier may request for disputed readings.
estimatedbooleanoptionaltrue if estimated rather than physically taken

meters optional #

All sub-fields optional.

FieldTypeValidationEffect
mpanstring13 digitsSkips Electralink electricity lookup
mprnstring6–10 digitsSkips Electralink gas lookup
current_supplier_electricitystringMax 100 charsSkips Electralink lookup
current_supplier_gasstringMax 100 charsSkips Electralink lookup
FieldTypeRequiredValidationNotes
data_sharing_acceptedbooleanrequiredMust be literal trueGDPR lawful basis. Rejected if false.
data_sharing_accepted_atstringrequiredISO 8601 datetimeWhen consent was captured
marketing_acceptedbooleanoptionalDefault: false
Consent vs LoA

This consent object covers data sharing and processing consent only (GDPR). The Letter of Authority — authorising HelloBill to act on the customer's behalf with utility suppliers — is a separate signature captured later inside the embed flow.

context optional, array #

Typed key–value metadata. Echoed verbatim in all webhook payloads under data.context.

json
"context": [
  { "name": "device_type",   "type": "string",  "value": "iPhone 16 Pro" },
  { "name": "utm_source",    "type": "string",  "value": "email" },
  { "name": "gl_tenancy_id", "type": "string",  "value": "TEN-89234" },
  { "name": "session_seconds","type": "number", "value": 42 },
  { "name": "marketing_opt_in","type": "boolean","value": false }
]

Each entry is one of:

typescript
| { name: string; type: 'string';  value: string  }
| { name: string; type: 'number';  value: number  }
| { name: string; type: 'boolean'; value: boolean }

Constraints: max 50 entries · name max 100 chars · string value max 500 chars · name unique within the array.

Idempotency #

POST /partner/sessions (and the other state-changing POSTs — /loa, /bank-details, /customers) support an explicit Idempotency-Key request header. There is no automatic deduplication based on customer email or address; idempotency is keyed solely on the header you send.

PropertyBehaviour
Key transportIdempotency-Key request header (opaque string; UUID v4 recommended)
StorageThe key is SHA-256 hashed and persisted server-side with the response
Window24 hours
Same key + same bodyReturns the original stored response (200 OK for the repeat; the first call returned 201 Created)
Same key + different bodyReturns 409 idempotency.fingerprint_mismatch — the key is bound to the exact request body it was first used with
No key suppliedEach call creates a new resource — no deduplication
Practical guidance

Generate one Idempotency-Key per logical operation (e.g. one per customer's session-creation attempt) and reuse it on network-failure retries. Do not reuse a key across genuinely different requests, or you will get a 409.

Response — 201 Created (or 200 on idempotent match) #

json
{
  "session_id": "ses_01HX7G2K...",
  "session_token": "eyJhbGciOiJIUzI1NiIs...",
  "embed_base_url": "https://embed-sandbox.hellobill.app",
  "discovery_status": "running",
  "estimated_ready_seconds": 8,
  "provided_fields": [
    "customer.title", "customer.first_name", "customer.last_name",
    "customer.full_legal_name", "customer.email",
    "addresses.current.postcode", "addresses.current.address_line_1",
    "addresses.current.bedrooms", "move.in.move_in_date",
    "addresses.previous.postcode", "addresses.previous.address_line_1",
    "move.out.move_out_date", "move.out.forwarding_address.postcode",
    "move.out.notify.energy", "move.out.notify.water",
    "customer.credit_check.previous_addresses"
  ],
  "expires_at": "2026-05-11T14:30:00Z",
  "gmaps_key": "AIza...",
  "feature_flags": {
    "direct_debit": true,
    "loa_signing": true,
    "allow_removal_services": false,
    "allow_electoral_roll": false,
    "priority_supplier_name": null
  },
  "journey_type": "move_in"
}
FieldTypeAlways presentNotes
session_idstringyesStable reference for API calls and webhooks
session_tokenstringyesHS256 JWT, 7200 s TTL at 8fca443c8 (SANDBOX_SESSION_TOKEN_TTL_SEC; earlier revisions quoted 300 s, which does not match the shipped default). Pass to embed SDK.
embed_base_urlstringyesEmbed origin only (e.g. https://embed-sandbox.hellobill.app), no path or token. The SDK composes the iframe URL as {embed_base_url}/{sdk_version}/onboard?token={session_token}. Origin-only is enforced (a URL with a path is rejected).
discovery_statusenumyesrunning | complete
estimated_ready_secondsintegernoOmitted when discovery_status is complete
provided_fieldsstring[]yesDot-notation paths of recognised fields
expires_atstringyesSession expiry — 30 days from creation. After this the session_id returns 404.
gmaps_keystringnoGoogle Maps Embed API key for the embed's address/map screen. Present when configured for the partner/environment; the SDK forwards it to the iframe as integrations.gmapsKey. Never required for Direct API integrations.
feature_flagsobjectno(1.21) Resolved partner feature flags, snapshotted at session creation. See callout below. Optional on the schema (responses cached before it existed still parse); when absent, treat every flag as enabled.
journey_typeenumno(1.21) Resolved onboarding journey — move_in | move. Per-session override, else the partner default (move_in).

Feature flags (1.21)

The feature_flags object echoes the partner's resolved flags, snapshotted at session creation — changing a partner profile does not retroactively alter an existing session's snapshot. The embed acts on direct_debit and loa_signing for page-hiding; the allow_* flags are enforced server-side by product filtering and appear here for transparency.

FlagTypeDefaultEffect
direct_debitbooleantrueEnables the Direct Debit setup step in the embed.
loa_signingbooleantrueEnables the Letter-of-Authority signing step.
allow_removal_servicesbooleanfalseSurfaces removal / add-on services (also gated server-side).
allow_electoral_rollbooleanfalseSurfaces the electoral-roll step.
priority_supplier_namestring | nullnullPins the named energy supplier to the top of the picker (case-insensitive match); null = no priority.

List sessions #

GET /api/v1/partner/sessions

Returns recently-created sessions for the authenticated partner, filterable by tenant email and/or postcode. Useful when a partner has lost a session_id (e.g. support ticket) or wants to reconcile their own records against HelloBill.

Query parameters

ParameterRequiredDefaultNotes
emailoptionalFilter by customer.email (exact match)
postcodeoptionalFilter by addresses.current.postcode (normalised)
created_afteroptional30 days agoISO 8601 datetime lower bound
created_beforeoptionalnowISO 8601 datetime upper bound
statusoptionalallcreated | customer_created | expired
cursoroptionalnullOpaque cursor from previous response
limitoptional50Integer 1–200
json
{
  "sessions": [
    {
      "session_id": "ses_01HX7G2K...",
      "referral_id": "REF-2026-05-10-abc123",
      "email": "anya@example.com",
      "postcode": "SW8 2JB",
      "address_line_1": "14 Clapham Road",
      "status": "customer_created",
      "customer_id": "CU-7310-5480-8",
      "created_at": "2026-05-10T14:30:00Z",
      "expires_at": "2026-05-11T14:30:00Z"
    }
  ],
  "next_cursor": "eyJvZmZzZXQiOjUwfQ=="
}

Listing returns a SessionSummary projection — not the full session payload. Each entry contains: session_id, referral_id?, email, postcode, address_line_1, status, customer_id?, created_at, expires_at, discovery_status?. The flat postcode and address_line_1 fields refer to addresses.current.

Single session fetch #

GET /api/v1/partner/sessions/:id

Retrieves the summary for a single session by ID. Returns a single SessionSummary projection — the same shape as one element of the sessions[] array from the list endpoint: session_id, referral_id?, email, postcode, address_line_1, status, customer_id?, created_at, expires_at, discovery_status?. This is not a full payload echo of the session creation request.

http
GET /api/v1/partner/sessions/ses_01HX7G2K...
Authorization: Bearer {access_token}

A session belonging to a different partner returns 404 session.not_found (see resource scoping).

Bank details optional #

Some integrations need to capture the customer's UK bank account details so HelloBill can set up Direct Debit mandates with utility suppliers on the customer's behalf. This endpoint is optional — sessions can complete account creation without it (e.g. when DD setup is deferred to supplier portals).

The endpoint accepts standard UK bank account credentials (sort code + account number) plus consent confirmation that the customer accepts the Direct Debit Guarantee. Account number is validated against the UK BACS modulus-checking algorithm (Vocalink standard) in addition to shape validation.

Embed SDK: Direct Debit is handled by the embed

The browser Embed SDK (@hello-bill/sdk) includes a Direct Debit screen in the embed iframe. The embed collects the customer's sort code, account number, and Direct Debit Guarantee consent, then submits them itself (after LoA signing, before account creation). Partners using the embed do not build their own DD collection screen and do not call this endpoint themselves.

The POST /partner/sessions/:id/bank-details endpoint documented below is for Direct API / server-side partners who are not using the embed iframe.

Bank details are customer-first (1.21)

POST /api/v1/partner/sessions/:id/bank-details requires the customer to already exist for the session; if none does it returns 404 customer.not_found. Direct Debit is captured after account creation. Submitting bank details successfully is also what triggers the customer-info magic link and supplier switching (once, at-most-once).

POST bank-details #

POST /api/v1/partner/sessions/:id/bank-details

Request headers

HeaderRequiredNotes
AuthorizationrequiredBearer {access_token}
Content-Typerequiredapplication/json
Idempotency-KeyrecommendedUUID v4. Safe to retry on network failure.

Request body

json
{
  "sort_code": "089999",
  "account_number": "66374958",
  "account_holder_name": "John Smith",
  "consent": {
    "direct_debit_authorised": true,
    "authorised_at": "2026-05-19T10:00:00Z",
    "consent_text_version": "ddg-v1"
  }
}
FieldTypeRequiredNotes
sort_codestringrequired6 digits. May be supplied with hyphens (08-99-99) or spaces; HelloBill normalises to digits-only.
account_numberstringrequired6–10 digits. Most UK accounts are 8 digits.
account_holder_namestringrequired1–100 characters.
consent.direct_debit_authorisedbooleanrequiredMust be literal true. Submissions with false or omitted are rejected with 400 validation.failed. Field name follows UK DDIM terminology.
consent.authorised_atstringrequiredISO 8601 timestamp of when the customer authorised the mandate.
consent.consent_text_versionstringrequiredString identifier for the consent copy shown to the customer (allows audit replay).

Response — 200 OK

json
{
  "session_id": "ses_abc123",
  "bank_details": {
    "id": "bnk_xyz789",
    "account_number_ending": "4958",
    "sort_code_masked": "08-**-99",
    "account_holder_name": "John Smith",
    "bank_name": "Barclays",
    "modulus_check_passed": true,
    "status": "collected",
    "collected_at": "2026-05-19T10:00:01Z",
    "consent": {
      "direct_debit_authorised": true,
      "authorised_at": "2026-05-19T10:00:00Z",
      "consent_text_version": "ddg-v1"
    }
  },
  "expires_at": "2026-05-20T10:00:00Z"
}
Privacy & masking

Full account number is never returned. Only account_number_ending (last 4 digits) appears in subsequent responses. sort_code_masked reveals first 2 + last 2 digits with middle 2 redacted (format NN-**-NN). bank_name is HelloBill's best-effort lookup from the sort code (may be null for non-mainstream banks).

status field — forward compatibility

The status field is 'collected' immediately after a successful POST. Future revisions may introduce 'pending' (async second-line bank verification queued) and 'failed' (post-collection verification failure detected before the first Direct Debit attempt). Handle the three-state union to be forward-compatible: 'collected' | 'pending' | 'failed'.

GET bank-details #

GET /api/v1/partner/sessions/:id/bank-details

Retrieve previously-collected bank details (masked). Returns the same BankDetailsResponse shape as the POST response.

http
GET /api/v1/partner/sessions/ses_abc123/bank-details
Authorization: Bearer {access_token}

Validation & BACS modulus check #

Two-stage validation is applied on every POST:

  1. Stage 1 — Shape. sort_code matches /^\d{6}$/ after normalisation. account_number matches /^\d{6,10}$/. account_holder_name length 1–100 after trimming.
  2. Stage 2 — UK BACS Modulus Check. Applies Vocalink's modulus-checking algorithm using current valacdos.txt weight tables and scsubtab.txt sort-code substitutions. Algorithms applied per sort-code range: MOD10, MOD11, or DBLAL, with exceptions 1–14 as published. Failures return 422 bank_details.invalid_modulus.

Idempotency

Follows the standard idempotency contract (see Idempotency). Supply an Idempotency-Key header. Replaying the same key with the same body returns the previously created BankDetailsObject without re-validating; the same key with a different body returns 409 idempotency.fingerprint_mismatch.

Without a key, re-submitting bank details for the same session replaces the stored record (latest write wins) — until the session is finalised (post-account-creation), after which submissions return 409 bank_details.session_locked.

Errors

HTTPerror.codeWhen
400validation.failedMissing/invalid fields, or consent.direct_debit_authorised not true
422bank_details.invalid_modulusAccount number fails the BACS modulus check
404session.not_foundSession does not exist or is expired
404bank_details.not_found(GET only) No bank details collected for this session
409bank_details.session_lockedSession already finalised (post-account-creation)
409idempotency.fingerprint_mismatchIdempotency-Key reused with a different body
Legacy codes not emitted

Earlier spec revisions listed bank_details.invalid_sort_code, bank_details.invalid_account_number, bank_details.modulus_check_failed, and consent.required. The current implementation does not emit those: shape problems return validation.failed (400) and modulus failures return bank_details.invalid_modulus (422).

Webhook

On successful collection, bank_details.collected is enqueued. At 8fca443c8 this event is emitted with a thin payload (both tiers) and is delivered on the sandbox tier only — see Event reference and the per-tier emission & delivery-gap callout for the documented (schema) shape versus what the current build actually sends.

How the Embed SDK works #

Two moving parts: the server middleware (mounts under a base path on your backend) and the client embed (renders in your frontend, calls the middleware). Credentials live server-side. The browser never sees them.

  1. Server start. Your backend mounts createHellobillRouter({clientId, clientSecret, buildSessionPayload}) once at boot.
  2. Customer clicks "Set up". Your frontend calls HelloBill.init({baseEndpoint, sessionData}).
  3. Embed → your middleware. The embed POSTs to {baseEndpoint}/session with the session data your frontend supplied.
  4. Middleware → HelloBill API. Your buildSessionPayload runs server-side, transforms the data, and the middleware calls POST /api/v1/partner/sessions with your client_secret-derived access_token.
  5. HelloBill → middleware. Returns session_id + session_token + provided_fields. Async product discovery starts in the background.
  6. Middleware → embed. Returns only the session_token — credentials stay on your server.
  7. Embed renders. Exchanges the token for session context; adaptive flow skips screens for any field listed in provided_fields.
  8. Customer journey. Confirm details → select products → draw LoA signature on screen.
  9. HelloBill → your webhook endpoint. On the sandbox tier the simulated timeline delivers a per-scenario subset of the catalogue (e.g. session.embed_opened, session.products_selected, session.loa_signed, session.account_created). Live emission and delivery differ — at 8fca443c8 live rows are enqueued but not delivered, and the live account event is account.created (not session.account_created). See the per-tier emission & delivery-gap callout under Delivery contract.
  10. Embed → your onComplete. Returns {customer_id, selected_products, loa_signed_at}. You redirect the customer to your next screen.

The flow above maps to four concrete pieces of code in your repo: one server mount, one frontend mount, one webhook handler, and an optional onComplete callback. We'll write each.

Server — middleware setup #

Mount @hello-bill/node under a single base path on your server. The package exposes adapters for the major Node frameworks; pick yours.

Express · Hono · Koa

import express from 'express';
import { createHellobillRouter } from '@hello-bill/node/express';

const app = express();
app.use(express.json());

app.use('/api/hellobill', createHellobillRouter({
  clientId:     process.env.HELLOBILL_CLIENT_ID,    // sb_ = sandbox, live_ = production
  clientSecret: process.env.HELLOBILL_CLIENT_SECRET,

  buildSessionPayload: async (req) => ({
    referral_id: req.body.referralId,

    customer: {
      type:       'tenant',
      title:      req.body.title,        // 1.19 — required: 'mr'|'mrs'|'miss'|'ms'|'mx'|'dr'|'other'
      first_name: req.body.firstName,    // required unless type === 'management_company'
      last_name:  req.body.lastName,
      full_legal_name: req.body.fullLegalName, // 1.19 — required for management_company; recommended otherwise
      email:      req.body.email,
      phone:      req.body.phone,
      // Optional B2B: account holder's address differs from the property
      address:    req.body.customerAddress ?? undefined,
    },

    addresses: {
      current: {
        address_line_1:   req.body.addressLine1,
        address_line_2:   req.body.addressLine2,
        address_line_3:   req.body.addressLine3,     // NEW v11 — third line
        city:             req.body.city,
        postcode:         req.body.postcode,
        bedrooms:         req.body.bedrooms,
        number_occupants: req.body.numberOfOccupants,
      },
      // Optional: previous property for move-out journeys
      previous: req.body.previousAddress ?? undefined,
    },

    move: {
      in: {
        move_in_date: req.body.moveInDate,
        bill_type:    req.body.billType ?? 'single',
      },
      // Optional: when partner wants HelloBill to notify incumbents at the previous address
      out: req.body.moveOut ?? undefined,
    },

    consent: {
      data_sharing_accepted:    true,
      data_sharing_accepted_at: new Date().toISOString(),
    },

    context: [
      { name: 'device_type',   type: 'string', value: req.headers['x-device-type'] ?? 'unknown' },
      { name: 'utm_source',    type: 'string', value: req.body.utmSource ?? '' },
      { name: 'gl_tenancy_id', type: 'string', value: req.body.tenancyId },
    ],
  }),
}));
import { Hono } from 'hono';
import { createHellobillRouter } from '@hello-bill/node/hono';

const app = new Hono();

app.route('/api/hellobill', createHellobillRouter({
  clientId:     process.env.HELLOBILL_CLIENT_ID!,
  clientSecret: process.env.HELLOBILL_CLIENT_SECRET!,
  buildSessionPayload: async (c) => {
    const body = await c.req.json();
    return {
      customer: { type: 'tenant', title: body.title, email: body.email, first_name: body.firstName, last_name: body.lastName, full_legal_name: body.fullLegalName },
      addresses: {
        current: { address_line_1: body.addressLine1, city: body.city, postcode: body.postcode, bedrooms: body.bedrooms },
        previous: body.previousAddress ?? undefined,
      },
      move: {
        in:  { move_in_date: body.moveInDate, bill_type: body.billType ?? 'single' },
        out: body.moveOut ?? undefined,
      },
      consent:   { data_sharing_accepted: true, data_sharing_accepted_at: new Date().toISOString() },
      context:   [{ name: 'gl_tenancy_id', type: 'string', value: body.tenancyId }],
    };
  },
}));
import Koa from 'koa';
import Router from '@koa/router';
import { createHellobillRouter } from '@hello-bill/node/koa';

const app = new Koa();
const router = new Router();

router.use('/api/hellobill', createHellobillRouter({
  clientId:     process.env.HELLOBILL_CLIENT_ID!,
  clientSecret: process.env.HELLOBILL_CLIENT_SECRET!,
  buildSessionPayload: async (ctx) => ({
    customer:  { type: 'tenant', email: ctx.request.body.email },
    addresses: {
      current: { address_line_1: ctx.request.body.addressLine1, city: ctx.request.body.city, postcode: ctx.request.body.postcode },
    },
    move:      { in: { move_in_date: ctx.request.body.moveInDate } },
    consent:   { data_sharing_accepted: true, data_sharing_accepted_at: new Date().toISOString() },
  }),
}).routes());

app.use(router.routes());

This mounts a small set of routes under /api/hellobill — session creation, products, LoA capture, status polling, customers, property enrichment, and bank details — that the embed calls from the browser. You only write buildSessionPayload; the SDK handles everything else.

HellobillHandlerSet routes

RouteMethodHandlerPurpose
{base}/sessionPOSTsessionCreates session, returns session_token
{base}/productsGETproductsProxies GET /partner/sessions/:id/products
{base}/loaGETloaProxies GET /partner/sessions/:id/loa. Fetch only — the middleware does not proxy POST /loa. Submit signatures inline via {base}/customers (or call POST /loa directly over HTTP for raw-HTTP Direct API flows).
{base}/statusGETstatusProxies GET /partner/customers/:id/status
{base}/customersPOSTcustomersProxies POST /partner/sessions/:id/customers
{base}/propertyGETpropertyProxies GET /partner/sessions/:id/property — enriched property characteristics
{base}/bank-detailsPOSTbankDetails.submitProxies POST /partner/sessions/:id/bank-details
{base}/bank-detailsGETbankDetails.getProxies GET /partner/sessions/:id/bank-details

Next.js (App Router)

typescript
// app/api/hellobill/[...slug]/route.ts
import { createHellobillHandler } from '@hello-bill/node/next';

export const POST = createHellobillHandler({
  clientId:     process.env.HELLOBILL_CLIENT_ID!,
  clientSecret: process.env.HELLOBILL_CLIENT_SECRET!,
  buildSessionPayload: async (req) => {
    const body = await req.json();
    return {
      referral_id: body.referralId,
      customer: { type: 'tenant', title: body.title, email: body.email, first_name: body.firstName, last_name: body.lastName, full_legal_name: body.fullLegalName },
      addresses: {
        current: { address_line_1: body.addressLine1, city: body.city, postcode: body.postcode, bedrooms: body.bedrooms },
        previous: body.previousAddress ?? undefined,
      },
      move: {
        in:  { move_in_date: body.moveInDate, bill_type: body.billType ?? 'single' },
        out: body.moveOut ?? undefined,
      },
      consent: { data_sharing_accepted: true, data_sharing_accepted_at: new Date().toISOString() },
      context: [{ name: 'gl_tenancy_id', type: 'string', value: body.tenancyId }],
    };
  },
});

export const GET = POST;  // Same handler serves all sub-paths
Critical: CSRF + auth binding

Mount the middleware behind your existing auth/session check. The middleware doesn't know who your logged-in user is — it trusts you to have authenticated them. Implement CSRF / Origin checks on the mount path, and bind customer.email to your own session record. Don't trust sessionData.email from the browser blind.

Client — mounting the embed #

The frontend SDK takes a baseEndpoint pointing at your middleware mount path. The embed appends its own relative paths (/session, /loa, /status) — you do not configure each one.

<script src="https://embed-sandbox.hellobill.app/v1/sdk.js"></script>
<div id="hellobill-mount"></div>
<script>
  HelloBill.init({
    baseEndpoint: '/api/hellobill',
    mountTo: '#hellobill-mount',

    sessionData: {
      email:        currentCustomer.email,
      firstName:    currentCustomer.firstName,
      lastName:     currentCustomer.lastName,

      // Current property (move-in target)
      addressLine1: property.addressLine1,
      addressLine2: property.addressLine2,
      addressLine3: property.addressLine3,        // NEW v11 — third line
      city:         property.city,
      postcode:     property.postcode,
      bedrooms:     property.bedrooms,
      moveInDate:   occupancy.moveInDate,

      // Optional B2B — supply when account holder's address differs from property
      customerAddress: currentCustomer.address,

      // Optional move-out — supply for move-in + move-out journeys
      previousAddress: previousProperty,           // PropertyObject; null for move-in-only
      moveOut: undefined,                          // MoveOutObject; omit for move-in-only

      tenancyId:    occupancy.id,
      utmSource:    getUtmParam('utm_source'),
      referralPage: window.location.pathname,
    },

    onComplete(result) {
      // result: { customer_id, selected_products, loa_signed_at }
      window.location.href = '/move/complete?account=' + result.customer_id;
    },

    onClose(reason) {
      if (reason === 'user_dismissed') showReEngagePrompt();
    },

    // Optional — see Theming & customisation
    appearance: {
      theme: 'system',                  // 'hellobill' | 'light' | 'dark' | 'system'
      variables: {
        colorPrimary: '#1a73e8',
        colorOnPrimary: '#ffffff',
        borderRadius: '10px',
      },
    },
    locale: 'en-GB',
  });
</script>
import { HelloBillEmbed } from '@hello-bill/sdk';

const embed = HelloBillEmbed.init({
  baseEndpoint: '/api/hellobill',
  mountTo: '#hellobill-mount',
  sessionData: {
    email: customer.email, firstName: customer.firstName, lastName: customer.lastName,
    postcode: property.postcode, addressLine1: property.addressLine1,
    moveInDate: occupancy.moveInDate, tenancyId: occupancy.id,
  },
  onComplete: (r) => router.push(`/done?customer=${r.customer_id}`),
  onClose:    (reason) => reason === 'user_dismissed' && setShowReminder(true),
  onEvent:    (event)  => analytics.track(event.type, event.data),
});

// Programmatic teardown
embed.destroy();
import { HelloBillWidget } from '@hello-bill/sdk/react';

<HelloBillWidget
  baseEndpoint="/api/hellobill"
  sessionData={{ email: customer.email, postcode: property.postcode, tenancyId: occupancy.id }}
  onComplete={(r) => navigate(`/done?customer=${r.customer_id}`)}
  onClose={(reason) => reason === 'user_dismissed' && setShowReminder(true)}
/>
<HelloBillWidget
  base-endpoint="/api/hellobill"
  :session-data="{ email: customer.email, postcode: property.postcode }"
  @complete="(r) => navigateTo(`/done?customer=${r.customer_id}`)"
/>
<!-- Obtain session_token server-side via client.sessions.create() first -->
<iframe src="https://embed-sandbox.hellobill.app/v1/onboard?token={{ session_token }}"
  style="width:100%;height:700px;border:none" allow="payment" />
iframe fallback

Callbacks aren't available with the iframe approach — use webhooks instead. The embed works with no pre-filled session data (the customer fills everything in), but expect the longest journey (~17 screens, ~4 min). Pass any data you have for the shortest path.

No-JS path: /v1/onboard is a deploy-time alias

The bare-iframe path above uses /v1/onboard, which is a deploy-time alias to the current embed version. It is distinct from the SDK-composed path /{sdk_version}/onboard, where the version is pinned into the SDK build at release time. The alias exists specifically for the no-JS case. Do not hardcode a versioned path (e.g. /v18/onboard) in a bare iframe — use the /v1/onboard alias so the path stays valid across embed releases.

Callbacks

CallbackFires whenSignature
onCompleteCustomer finishes the flow (account created, LoA signed)(result: CompletionResult) => void
onCloseEmbed is dismissed before completion(reason: 'user_dismissed' | 'timeout' | 'error') => void
onEventLifecycle milestones (14 stable events). See Lifecycle events.(event: { type: LifecycleEventType; data }) => void
onUserActionGranular user-interaction events (higher cardinality, evolving). See User-action events.(action: { type: string; data }) => void
CompletionResult shape

result.customer_id · result.selected_products: { product_id, location }[] · result.loa_signed_at? · result.setup_status? · result.move_out_status?

Note: selected_products is an array of objects (not strings) — each entry carries both product_id and location: 'current' | 'previous'.

embedOrigin — enforced, not advisory

InitOptions accepts an optional embedOrigin. The SDK honors embedOrigin only when NODE_ENV !== 'production'; in production builds it is ignored and the origin returned by the API in embed_base_url is used. When honored (dev/UAT), the value is still validated against the compiled origin allowlist — an unrecognised origin is rejected. Example non-prod override: embedOrigin: 'https://embed.uat.the-bunch.co.uk'.

Tier selection — auto-detect by default, tier to pin (SDK 2.4.0)

Production serves two embed tiers from distinct origins: live (https://embed.hellobill.app) and sandbox (https://embed-sandbox.hellobill.app). Which one your session belongs to is decided by your credentials (the sb_ / live_ client-id prefix) and returned in embed_base_url — the SDK does not need to be told.

Default (unpinned): the SDK auto-detects the tier from the session's embed_base_url and mounts — any origin other than https://embed.hellobill.app, including dev/UAT hosts, auto-detects as sandbox. The selection is deliberately loud: a console.info line names the tier ("real customer journeys will occur" for live) and an operational.tier_selected lifecycle event fires with data: { tier, source: 'session' } before ready.

Pinned: pass tier: 'live' | 'sandbox' to HelloBill.init() (or resume()) to lock the integration to one tier. A session from the other tier then fails fast with an embedOrigin tier mismatch error that names the fix (init({ tier: '…' })). Pin when a credential mixup between deployments should be a hard error rather than a mounted embed. Pinned integrations emit no operational.tier_selected event. Precedence when several pins are present: init({ tier }) → legacy globalThis.__HB_TIER__ → the tier baked into CDN-served sdk.js builds → auto-detect.

Before SDK 2.4.0 the npm package was compiled sandbox-pinned and mounting a live session required the undocumented globalThis.__HB_TIER__ = 'live' global. That workaround still works but is superseded by init({ tier }).

Events & callbacks #

The embed surfaces two complementary event channels. onEvent carries stable lifecycle markers — a small, contract-stable set of well-defined journey milestones that map closely to Partner API state transitions. onUserAction carries granular user-interaction telemetry — richer, higher-cardinality, and additive over time. Partners can subscribe to either or both; the channels are independent.

ChannelCardinalityPurposeUse for
onEventLow (14 events)Lifecycle: well-defined milestones, mostly correlated with Partner API state changesFunnel analytics, server-side logging, lifecycle-driven UI updates
onUserActionHigher (~15 events; non-stable)Granular: in-flow user choices, screen transitions, micro-interactionsDetailed product analytics, A/B test instrumentation, replay reconstruction

Lifecycle events (onEvent) #

Event typeFired when
readyEmbed has mounted and is ready to receive interaction
session.createdSession record created upstream (post-POST /partner/sessions)
consent.grantedGDPR/data-sharing consent received from the customer
screen.shownA named screen has become visible. Use for funnel step gating.
loa.signedCustomer signature captured on the Letter of Authority
account.creatingPOST /partner/sessions/:id/customers in flight
account.createdCustomer record created upstream; result.customer_id available
errorRecoverable error visible to the customer (network, validation, etc.)
completeFlow completed successfully; mirrors onComplete for partners that prefer a single channel
closeFlow closed (user-dismissed, timeout, or error). Mirrors onClose
journey.completedCustomer reached the terminal success state of the journey (fires alongside complete in the two-event completion contract)
journey.closedJourney ended without completing (deduplicated per session)
operational.iframe_timeoutEmbed iframe failed to signal ready within the load window; the SDK rendered the open-in-new-tab fallback
operational.tier_selected (2.4.0)Unpinned integration auto-detected its tier from the session; data: { tier: 'live' | 'sandbox', source: 'session' }. Never fires for pinned integrations. See Tier selection.

User-action events (onUserAction) #

User-action events surface granular in-flow choices. The catalogue evolves as new screens are added; treat this stream as informational, not contractual. Names are guaranteed stable within a major release but may grow over time.

  • screen.entered — internal screen-transition marker (use screen.shown lifecycle event for funnel logic)
  • service.selected — customer toggles a service category on the service-select screen
  • product.chosen — customer commits to a specific tariff/product
  • card_preview.activated — customer expands a product-preview card
  • council.reduction_toggle — customer toggles council tax reduction declaration
  • discovery.started / discovery.complete — async discovery progress
  • setup.status / setup.complete — async setup progress (also surfaces via webhooks)
typescript
HelloBillEmbed.init({
  // ...
  onEvent: (event) => {
    // Stable, low-cardinality — feed your funnel.
    analytics.track('hellobill.lifecycle', event);
  },
  onUserAction: (action) => {
    // Information-dense, evolving — feed your product analytics.
    if (action.type === 'product.chosen') {
      productAnalytics.track('product_selected', action.data);
    }
  },
});
Migration from v1.11

onUserAction is additive in revision 1.12. Integrations on revision 1.11 that only consume onEvent continue to work. Granular events that previously surfaced via onEvent (e.g. service.selected, product.chosen) now route to onUserAction only. If you previously relied on receiving these via onEvent, add an onUserAction callback to keep observing them.

Property endpoint optional #

GET /api/v1/partner/sessions/:id/property

Returns enriched property characteristics for the session — building typology, energy performance, floor, parking, room counts — discovered or inferred after session creation (UPRN lookups, address-data providers, EPC register).

Optional for Embed SDK partners

You do not need to call this endpoint as part of the embed integration. The embed uses property data internally where relevant. Use this endpoint only if your own flow benefits from richer property context (rent-affordability tooling, home-insurance prefill, property-onboarding flows, energy advisory).

Request

http
GET /api/v1/partner/sessions/ses_01HX.../property
Authorization: Bearer {access_token}

Query parameters

ParameterRequiredDefaultNotes
force_refreshoptionalfalseRe-run property discovery, bypassing the 24 h cache. Same 5-min per-session throttle as GET /products.

Response — 200 OK

All fields inside property are optional / nullable — discovery may resolve any subset. Treat every field as possibly absent.

json
{
  "session_id": "ses_01HX...",
  "property": {
    "property_type_code": "house",
    "property_type_subcode": "terraced",
    "building_type": "residential",
    "building_style": "victorian",
    "number_of_bedrooms": 3,
    "number_of_bathrooms": 1.5,
    "floor_number": 0,
    "energy_performance_rating": "D",
    "has_parking": true,
    "property_has_off_road_parking": true,
    "property_has_underground_parking": false,
    "property_has_a_driveway": true,
    "bin_collection": {
      "primary_collection_day": "wednesday",
      "primary_collection_types": ["general_waste", "recycling"],
      "collection_frequency": "fortnightly",
      "next_collection_date": "2026-06-03",
      "days_after_move_in": 2,
      "reminder_eve_label": "Tuesday evening",
      "secondary_collection_day": "wednesday",
      "secondary_collection_types": ["food_waste"]
    }
  },
  "data_completeness": "partial",
  "extracted_at": "2026-05-13T14:50:00Z",
  "expires_at": "2026-05-14T14:50:00Z",
  "broadband": {
    "max_down_speed_mbps": 1000,
    "max_up_speed_mbps": 220
  }
}

Top-level response fields

FieldTypeAlways presentNotes
session_idstringyesEcho of the path parameter
propertyobjectyesThe property record. May be empty ({}) when data_completeness: 'empty'.
data_completenessenumyescomplete · partial · empty — qualitative signal of how much of the schema was populated
extracted_atstringyesISO 8601 — when the property data was resolved
expires_atstringyesISO 8601 — 24 h after extracted_at
broadbandobjectno(Added 1.16) Broadband availability at the property. Present when the postcode resolves a serviceability record. Fields: max_down_speed_mbps? (number ≥ 0), max_up_speed_mbps? (number ≥ 0).

property sub-fields

FieldTypeNotes
property_type_codeenumhouse · flat · maisonette · bungalow · other
property_type_subcodeenumdetached · semi_detached · terraced · end_terrace · purpose_built_flat · converted_flat · studio · penthouse · other
building_typeenumresidential · mixed_use · commercial · unknown
building_styleenumvictorian · edwardian · georgian · interwar · post_war · modern · new_build · period · unknown
number_of_bedroomsnumber≥ 0, decimals allowed for studios (0.5)
number_of_bathroomsnumber≥ 0, decimals allowed for half-baths (1.5)
floor_numberinteger−5 to 200. 0 = ground floor. Negative = basement. Omit / null for houses.
energy_performance_ratingenumAG
has_parkingbooleanRoll-up: true if any of the three specific parking flags below is true
property_has_off_road_parkingbooleanOff-road parking present (private bay, garage, etc.)
property_has_underground_parkingbooleanUnderground / basement parking
property_has_a_drivewaybooleanDriveway present
bin_collectionobject(Added 1.16) Refuse-collection schedule. Present for supported local authorities only. Fields: primary_collection_day (monday–sunday), primary_collection_types[] (general_waste/recycling/food_waste/garden_waste/glass), collection_frequency (weekly/fortnightly/monthly), next_collection_date (ISO date), days_after_move_in (integer), reminder_eve_label (string), secondary_collection_day?, secondary_collection_types?[].
Parking field semantics

has_parking is a roll-up convenience flag. A null on any specific parking flag means unknown (discovery couldn't determine it), not false. has_parking may be true while some specific flags are null — the roll-up reflects the known positives. Clients that only need "is there any parking?" should read has_parking.

Embed SDK integration

The SDK auto-fetches property data internally. Partners can use data_completeness to gate their own UI:

  • complete — confirmation-only flow; all fields resolved
  • partial — pre-filled quiz; some fields resolved
  • empty — full quiz; no data resolved

Error cases

HTTPcodeNotes
404session.not_foundSession ID not found for this partner
425property.discovery_in_progressFirst-time discovery still running. Response includes Retry-After (seconds).
429rate.limitedRate limit hit

When the property record exists but is empty (discovery completed and resolved nothing), the response is 200 OK with data_completeness: "empty" and property: {} — not an error.

Letter of Authority (LoA) flow #

The LoA authorises HelloBill to act on the customer's behalf with utility suppliers. With the Embed SDK, the LoA is captured inside the embed flow — the customer reviews the LoA text and signs with a drawn signature on screen. You do not need to render, capture, or submit it yourself.

LoA scope (template loa-template-v4)

A single signature covers both directions: switching at the current address (location: 'current' products) and closure / final read submission / change-of-tenancy notification at the previous address (location: 'previous' products, when the customer has opted in via move.out.notify.*). Partners do not need to capture two LoAs.

Standalone POST /loa endpoint (Direct API callers)

As of revision 1.16 there is a standalone POST /partner/sessions/:id/loa endpoint to submit a signed LoA before account creation. This is intended for raw-HTTP Direct API flows only. loa_signatures[] on POST /customers is now optional — submit the signature via either path, not both.

SDK behaviour: both the @hello-bill/node client (sessions.loa) and the embed iframe submit signatures inline via /customers. The node middleware wires only GET /loa (not POST /loa) and there is no sessions.loa.submit() method on the typed client. Call POST /loa directly over HTTP if you prefer the standalone path.

What happens behind the scenes (Embed SDK)

  1. Customer selects products inside the embed. If a selected product carries requires_slot_scheduling: true (e.g. a removals Van), the embed fetches available slots via GET /partner/sessions/:id/service-slots?service=removals&provider=any_van and presents a slot-picker screen. The chosen slot is submitted as a per-product selected_products[].selected_slot object on account creation. The selected_slot.product_id must equal the parent selected_products[].product_id of the slot-scheduling product it belongs to — a mismatch or a missing slot on a requires_slot_scheduling product is rejected (400 customer.missing_selected_slot), and a slot on a non-slot product is rejected (400 customer.unexpected_selected_slot). (1.19: removals is temporarily disabled in the sandbox catalog — no products carry requires_slot_scheduling, so the slot-picker screen is not shown.)
  2. (1.19) Credit-check addresses. If a selected product carries requires_credit_check_addresses: true (e.g. an OVO Energy tariff), the embed presents a credit-check address screen after product selection. It prefills the first entry from addresses.previous (address fields only) and lets the customer add/edit a residence history covering at least 3 years — each entry an address plus a move_in_date and (for all but the current residence) a move_out_date. The collected history is persisted to the session via PATCH /partner/sessions/:id (customer.credit_check.previous_addresses). At account creation the history must be sufficient (≥ 3 years, ≤ 31-day gaps) — otherwise 400 customer.missing_credit_check_addresses or 400 customer.credit_check_history_insufficient. Supplying credit-check addresses when no selected product requires them is rejected (400 customer.unexpected_credit_check_addresses). This mirrors the slot-scheduling pattern: the product dictates the follow-up step. See § Credit-check address screen.
  3. Embed fetches the appropriate LoA(s) via GET /loa?product_ids=....
  4. Embed fetches the LoA HTML for display via GET /api/v1/partner/loa/:id/document (returns { body_html, checksum }), using the embed session_token Bearer with scope loa:read. The embed sanitises body_html before rendering it. (1.22 — session-token scope.) Session tokens are minted on both tiers; this LoA-document route (and embed telemetry) accept a sandbox or live session token, whereas the protected partner router accepts a session token only as a sandbox-mode fallback for the credit-check PATCH and snapshotresume and all other partner calls require the partner M2M access token.
  5. LoA text is displayed; customer reviews and signs (PNG or JPEG drawn signature — SVG is not accepted).
  6. Embed submits signed LoA(s) inline as loa_signatures[] on account creation — including loa_id, signed_checksum, signed_at_client, and the base64 signature image.
  7. The session.loa_signed event is enqueued with loa_id and signed_at_server (the authoritative server timestamp). (1.22, 8fca443c8): the shipped producer emits a thin payload that omits the base referral_id/context and the schema's signed_by_full_name; delivery is sandbox-tier only (live rows are enqueued but not dispatched). See the per-tier emission & delivery-gap callout under Delivery contract for the documented (schema) shape versus what the current build sends.

Credit-check address screen 1.19 #

Some energy suppliers (e.g. OVO) require a credit check, which needs the customer's address history for the last 3 years. When the customer selects an energy product whose catalog entry carries requires_credit_check_addresses: true, the embed inserts a dedicated credit-check address screen into the flow, immediately after energy product selection. The screen is shown only when at least one selected product requires it — exactly the same product-dictates-the-follow-up pattern as the removals slot-picker.

  • Prefill. If addresses.previous was supplied at session creation, its address lines / city / postcode / UPRN prefill the first history entry. The occupancy dates are left blank for the customer to complete; property characteristics (bedrooms, EPC, …) are not carried over.
  • Add more. The customer can add additional previous addresses (an editable, repeatable list), each captured as a full address plus a move_in_date and a move_out_date. The most recent (current) residence omits move_out_date.
  • Sufficiency. The screen guides the customer until the history covers the 3-year window ending today (gaps of ≤ 31 days between consecutive residences are tolerated; overlaps are allowed and simply merge). A present-but-insufficient history does not let the customer proceed — the screen re-prompts for the missing years.
  • Persistence. The collected history is written to the session via PATCH /partner/sessions/:id (customer.credit_check.previous_addresses) so it survives drop-and-resume. The session_token's 7200 s TTL (at 8fca443c8) normally outlives the whole flow, so it is still valid by the time this screen is reached; there is no browser-side session-token refresh endpoint. An expired token returns 401 auth.invalid_credentials; a fresh token comes only from session creation or an M2M-authenticated resume.
  • Save / restore (1.21). The embed checkpoints in-flight progress via POST /api/v1/partner/sessions/:id/snapshot (current screen, selected products, pay-path, partial inputs) and restores it via POST /api/v1/partner/sessions/:id/resume, which returns a fresh session_token plus the saved snapshot (or null). Snapshots are writable until the customer is created, then frozen.
  • Enforcement. At account creation the server re-checks sufficiency. Failures: 400 customer.missing_credit_check_addresses (none supplied), 400 customer.credit_check_history_insufficient (present but < 3 years / has gaps; the response carries error.details.min_years_required and error.details.provided_years), 400 customer.unexpected_credit_check_addresses (supplied but no selected product requires them).
Direct API integrators

You can skip this screen entirely by supplying a complete customer.credit_check.previous_addresses history (≥ 3 years) in the session-creation payload (or via PATCH). The embed only shows the screen when the supplied history is insufficient for a selected credit-check product.

signed_at_server is authoritative

The webhook payload uses signed_at_server (not signed_at). The server stamps this at the moment the signature is validated and stored. Partner-supplied signed_at_client is advisory audit metadata only; signed_at_server determines the LoA's 12-month validity window.

LoA response fields

FieldAlways presentNotes
loa_idyesStable reference. Must be passed back when submitting the signature on account creation.
loa_text_urlyesURL to the PDF rendering. Requires Authorization: Bearer {access_token}. Never include as a bare link in HTML, emails, or logs — proxy server-side only.
loa_text_html_urlyesDocument URL for the LoA HTML the embed shows for signing. (1.17) Fetch via GET /api/v1/partner/loa/:id/document using the embed session_token Bearer (scope loa:read); returns { body_html, checksum }. The embed sanitises body_html before rendering. The endpoint responds with strict security headers (Cache-Control: no-store, Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none', X-Frame-Options: DENY) — do not cache or proxy this URL.
covers_product_idsyesThe product IDs this LoA authorises. Every product in the request must be covered by exactly one LoA.
checksumyesSHA-256 of the LoA text the customer is shown. Capture client-side and submit as signed_checksum to prove what was signed.
expires_atyesLoA template expiry — re-fetch after this time.
versionyesTemplate version identifier, e.g. loa-template-v4.

Legal & retention

  • LoAs expire after 12 months from signed_at_server.
  • Signed PDFs retained for 7 years (regulatory minimum).
  • Customer can revoke at any time — revocation blocks any further switching/relay.
  • Full audit trail captured: signature method, IP, user-agent, signed_at_server.

Theming & customisation #

appearance is an optional configuration block on HelloBill.init({ … }) that visually aligns the embed with your brand. The shipped surface today is a theme preset, disableAnimations, a partial variables set, mode, locale, integrations.gmapsKey, and the updateAppearance/setLocale/destroy handle methods. A rules allowlist is declared in the SDK types but is roadmap — not yet enforced by the current embed build. There is no CSS escape hatch — partners cannot inject arbitrary CSS into the embed.

Why curated, not free-form

The embed renders content that has legal weight in the UK (Letter of Authority, GDPR consent, PSR notice, Ofgem cooling-off disclosures). A curated surface lets partners brand the embed without being able to hide, shrink, or visually demote anything a regulator requires the customer to read.

The appearance object #

typescript
HelloBill.init({
  baseEndpoint: '/api/hellobill',
  mountTo: '#hellobill-mount',
  sessionData: { /* … */ },

  appearance: {
    theme: 'hellobill',                 // 'hellobill' | 'light' | 'dark' | 'system'
    disableAnimations: false,           // honours OS prefers-reduced-motion regardless
    variables: {
      colorPrimary:         '#1a73e8',
      colorOnPrimary:       '#ffffff',
      colorBackground:      '#ffffff',
      colorSurface:         '#f7f9fc',
      colorBorder:          '#e4e8ee',
      colorText:            '#0f172a',
      colorTextSecondary:   '#475569',
      colorTextPlaceholder: '#94a3b8',
      colorTextDisabled:    '#cbd5e1',
      colorDanger:          '#c43c3c',
      colorSuccess:         '#16a34a',
      colorWarning:         '#b97900',
      fontFamily:           'Inter, -apple-system, sans-serif',
      fontSizeBase:         '16px',
      fontLineHeight:       '1.55',
      fontWeightNormal:     '400',
      fontWeightMedium:     '500',
      fontWeightBold:       '700',
      spacingUnit:          '4px',
      gridRowSpacing:       '16px',
      gridColumnSpacing:    '16px',
      borderRadius:         '8px',
      buttonBorderRadius:   '8px',
      focusBoxShadow:       '0 0 0 3px rgba(26,115,232,0.25)',
      focusOutline:         '2px solid #1a73e8',
      iconColor:            '#475569',
      iconHoverColor:       '#0f172a',
      iconCheckmarkColor:   '#ffffff',
    },
    rules: {
      '.HBButton':                 { fontWeight: '600' },
      '.HBButton:hover':           { boxShadow: '0 2px 8px rgba(0,0,0,0.08)' },
      '.HBInput':                  { borderColor: '#dbe2ea' },
      '.HBInput:focus':            { borderColor: '#1a73e8' },
      '.HBInput--invalid':         { borderColor: '#c43c3c' },
      '.HBCard':                   { boxShadow: '0 1px 3px rgba(0,0,0,0.04)' },
      '.HBCard--selected':         { borderColor: '#1a73e8' },
    },
  },

  locale: 'en-GB',                      // 'auto' | 'en' | 'en-GB' | 'cy-GB'
});
FieldTypeRequiredDefaultNotes
themeenumoptional'hellobill''hellobill' | 'light' | 'dark' | 'system'
disableAnimationsbooleanoptionalfalseWhen false, the OS prefers-reduced-motion: reduce setting still disables animations.
variablesobjectoptional{}Design-token overrides. Merged on top of the chosen theme.
rulesobjectoptional{}Roadmap — not yet enforced. Curated selector → CSS-property map. Declared in SDK types but silently ignored by the current embed build. See Roadmap below.

Shipped surface (current build)

These options take effect today in the current embed build:

Theme presets #

PresetBehaviour
'hellobill' defaultHelloBill's branded palette — mint accent on light surfaces. Use when you want a “powered by HelloBill” look.
'light'Neutral light theme. Partner-agnostic palette ready for token overrides.
'dark'Neutral dark theme — colorBackground: #0f172a, colorText: #e2e8f0, etc.
'system'Resolves to 'light' or 'dark' based on prefers-color-scheme. The embed subscribes to changes via MediaQueryList.addEventListener('change', …) and updates live — no page reload.

A theme is a starting set of variables values; anything you provide in appearance.variables overrides the theme defaults. Merge order: theme defaults → variables → rules.

Variable reference #

All values are CSS strings. Hex, rgb(), rgba(), and CSS-variable references (var(--my-token)) are accepted; CSS expressions, url(), and @import are stripped.

CategoryTokens
Colour — basecolorPrimary, colorOnPrimary, colorBackground, colorSurface, colorBorder
Colour — textcolorText, colorTextSecondary, colorTextPlaceholder, colorTextDisabled
Colour — semanticcolorDanger, colorSuccess, colorWarning
Colour — iconiconColor, iconHoverColor, iconCheckmarkColor
TypographyfontFamily, fontSizeBase, fontLineHeight, fontWeightNormal, fontWeightMedium, fontWeightBold
SpacingspacingUnit, gridRowSpacing, gridColumnSpacing
Radius & focusborderRadius, buttonBorderRadius, focusBoxShadow, focusOutline
colorOnPrimary

This is the text/icon colour used on the primary fill (e.g. on the primary CTA button). Partners must set both; the SDK does not auto-derive a readable foreground because brand teams typically prefer to pick it deliberately.

Fonts

fontFamily accepts any valid CSS font-family value, but the SDK does not fetch external font files on your behalf. To use a custom web font, either: (a) ensure the font is already loaded on the page where the embed mounts, then reference it by name; or (b) preload a self-hosted font via your own <link rel="preload">. This protects your CSP and avoids third-party-font GDPR exposure.

Roadmap (declared in types, not yet enforced)

Not yet enforced

The following theming surfaces are present in the SDK's TypeScript types and documented design intent, but are not enforced by the current embed build. Do not rely on them in production yet; they will be honoured in a future revision.

Rules — curated selector allowlist #

rules is a map: selector → { cssProperty: value }. Selectors are HelloBill-specific class names (prefix HB). Selectors not in the allowlist are silently ignored.

SelectorTargetsNotable allowed properties
.HBLabelField labelscolor, fontWeight, fontSize, letterSpacing, textTransform
.HBInputText inputsborderColor, borderWidth, backgroundColor, color, padding, boxShadow
.HBInput:focus, .HBInput:hover, .HBInput--invalid, .HBInput::placeholderInput statessame as .HBInput
.HBButton, .HBButton:hover, .HBButton:focus, .HBButton:disabledPrimary CTA buttonsbackgroundColor, color, borderColor, boxShadow, fontWeight, transform
.HBCard, .HBCard--selected, .HBCard:hoverProduct cards in the pickerbackgroundColor, borderColor, boxShadow, borderWidth
.HBCheckbox, .HBCheckboxLabel, .HBCheckboxInputCheckboxes (excluding consent — see lock list)borderColor, color, backgroundColor
.HBRadioIcon, .HBRadioIconOuter, .HBRadioIconInnerRadiosborderColor, backgroundColor, fill
.HBTab, .HBTab:hover, .HBTab--selectedSection tabscolor, backgroundColor, borderBottomColor, fontWeight
.HBAccordionItemCollapsible disclosure rowsbackgroundColor, borderColor
.HBErrorInline validation messagescolor, backgroundColor, borderColor
.HBMenu, .HBMenuActionDropdown menusbackgroundColor, borderColor, color, boxShadow
Properties that are never allowed inside rules

Regardless of selector: position, display, visibility, opacity, pointer-events, z-index, transform: scale(0), width: 0, height: 0, overflow: hidden on layout containers, clip-path, font-size: 0. Attempts to use these are silently dropped. This list exists to prevent partners from accidentally (or deliberately) hiding required disclosures.

Lock list — what cannot be themed #

The following elements are rendered with HelloBill's own styles and ignore appearance.variables and appearance.rules. This is by design and not configurable.

ElementReason
Letter of Authority disclosure text and signature canvasLegal — customer must clearly see what they are authorising. Re-styling could undermine consent quality.
GDPR consent screens (data_sharing_accepted, marketing opt-in)UK GDPR Art. 7(2) + ICO guidance: consent must be clear, prominent, and the reject path visually equivalent to the accept path. No “dark patterns”.
Priority Services Register (PSR) disclosureOfgem/Ofwat customer-information rules require prominent, accessible presentation.
Switching cooling-off noticeOfgem switching rules require the 14-day cooling-off period to be visible at the point of decision.
Tariff Comparison Rate, where shownOfgem tariff disclosure standard requires consistent presentation across suppliers.
Supplier logos rendered from supplier_logo_urlTrust signal — partners cannot swap or restyle real supplier marks.
“Powered by HelloBill” footerTrust + auditability — customers must know who their data is being shared with.
Error / failure UI for switchingCustomers must clearly understand when something failed; we keep this UI consistent across all partners.

Accessibility & contrast guarantees #

Roadmap. WCAG 2.2 AA runtime contrast guarantees are declared in the SDK's TypeScript types but are not yet enforced by the current embed build. The behaviour below is the intended future behaviour and will ship in a forthcoming revision.

PairMinimum ratioBehaviour on fail
colorText on colorBackground4.5:1 (normal text)Dev-console warning; locked surfaces use fallback colorText from the active theme.
colorOnPrimary on colorPrimary4.5:1Dev-console warning; the SDK falls back to white or black (whichever satisfies the ratio) for the locked surfaces.
Focus indicators (focusOutline, focusBoxShadow) on colorBackground3:1Dev-console warning; SDK overlays its default focus ring on locked surfaces.
Non-text UI (.HBInput border, .HBCheckbox icon)3:1Dev-console warning.

Disabled-state elements are exempt from contrast minimums per WCAG 2.2 SC 1.4.3.

prefers-reduced-motion is always honoured

The SDK disables non-essential animations even when disableAnimations: false. There is no partner override for this.

Live theme switching #

HelloBill.init(…) returns an instance that exposes:

typescript
embed.updateAppearance({ theme: 'dark' });   // merges; partial updates supported
embed.updateAppearance({ variables: { colorPrimary: '#ff8800' } });
embed.setLocale('cy-GB');                    // also re-renders strings
embed.destroy();                             // unmounts and tears down listeners

When theme: 'system', the embed subscribes to matchMedia('(prefers-color-scheme: dark)') itself — partners do not need to wire this up. Use embed.updateAppearance(…) only when your app has its own theme picker that should override the OS preference.

Locale #

ValueBehaviour
'auto' defaultResolved from navigator.language, falling back to 'en-GB'.
'en'Generic English.
'en-GB'UK English with UK-specific copy (postcodes, supplier names, regulator references).
'cy-GB'Welsh — recommended whenever you serve Welsh-speaking customers, and required for partners with obligations under the Welsh Language (Wales) Measure 2011 (typically public bodies or designated organisations). All embed strings are translated; LoA legal text is presented bilingually (English alongside Welsh).

Locale changes via embed.setLocale(…) re-render strings in place; no page reload.

Implementation notes #

  • CSP. The embed is served from https://embed-sandbox.hellobill.app (sandbox) or https://embed.hellobill.app (live). Add the origin(s) for your tier(s) to your frame-src and connect-src. Inline styles inside the iframe are scoped — no CSP exception needed on the parent page.
  • Iframe transport. Appearance is posted to the iframe via postMessage on init and on every updateAppearance(…). The embed never reads from your stylesheet — partner and embed live in isolated style scopes.
  • No third-party fonts. The SDK never fetches fonts on your behalf. Preload any custom font on the parent page or the iframe origin.
  • No theme: 'auto'. Use theme: 'system' instead; 'auto' is reserved for locale.
  • Defaults are good. The 'hellobill' theme works without any other configuration — appearance is entirely optional.
  • M1/M2 security guards. The SDK enforces two client-side security layers, transparent to partners. M1 (tier check): since SDK 2.4.0 the npm build auto-detects the tier from the session's embed_base_url (loudly — see § Tier selection) and rejects cross-tier embed origins only when the integration is pinned via init({ tier }), the legacy __HB_TIER__ global, or a CDN-baked build. M2 (token-tier binding): the session token carries a tier/mode claim; the embed refuses to mount if the token's tier doesn't match its own. M2 failures surface as an onEvent 'error' event; M1 pin mismatches throw from init()/resume() with an embedOrigin tier mismatch message (synchronously when an embedOrigin option reveals the mismatch immediately; otherwise as a rejected promise after the session fetch) — there is no HTTP status code, as these are client-side guards. Together they form a defense-in-depth layer; no partner action is required.

Google Maps integration (integrations.gmapsKey) #

The embed uses a Google Maps embed to display the current property on the address-confirmation screen. Pass your Maps Embed API key via integrations.gmapsKey:

typescript
HelloBill.init({
  baseEndpoint: '/api/hellobill',
  mountTo: '#hellobill-mount',
  sessionData: { /* ... */ },
  integrations: {
    gmapsKey: process.env.NEXT_PUBLIC_GMAPS_KEY,
  },
});
BehaviourDetail
Key handlingEmbedded client-side in the Maps Embed URL (https://www.google.com/maps/embed/v1/place?key=…). Never sent to HelloBill servers.
Production keysMust use HTTP referrer restrictions scoped to your partner domain(s) (e.g. https://yourapp.com/*). An unrestricted key is a billing risk.
No key providedEmbed falls back to an inline SVG map placeholder. No error is thrown and the journey continues normally — degradation only.
integrations fieldEntirely optional. Omitting it is equivalent to integrations: {}.

Recommended Google Cloud Console setup

  1. Create a Maps Embed API key (not Maps JavaScript API — the embed uses the simpler Embed API).
  2. Under "Application restrictions", select "HTTP referrers (web sites)".
  3. Add your partner domain(s): https://yourapp.com/* and https://staging.yourapp.com/*.
  4. Under "API restrictions", restrict to "Maps Embed API" only.

Webhooks #

Webhooks are how you observe what happens after the embed hands off: switching progress, supplier confirmations, subscription state changes. Register your endpoint in PartnerDock; we'll send signed POST requests.

Delivery contract

PropertyGuarantee
DeliveryAt-least-once. Same event may be delivered more than once.
OrderingNot guaranteed. Events may arrive out of order.
LatencyBest-effort within 30 s of the triggering action.
IdempotencyUse the id field to deduplicate.
Consuming webhooks correctly

Verify signature → respond 2xx within 10 s → process asynchronously → deduplicate on id → do not rely on ordering; use created_at to sequence if needed. Parse defensively: tolerate an unrecognised type and missing base fields (see envelope deviations below).

Per-tier emission & delivery gap (1.22, 8fca443c8)

The event catalogue below is the shared schema; which events a tier enqueues, and whether they are delivered, differ by tier:

  • Sandbox drives a simulated onboarding timeline via an internal scheduler. Across fixtures it can produce every catalogue variant, but any one session emits only the applicable subset (e.g. app_installed or app_deferred; move_out_notification.sent or .failed). Sandbox rows are delivered.
  • Live enqueues real-action events only: session.embed_opened, session.loa_signed, account.created (that literal wire typenot session.account_created; the sandbox tier uses session.account_created), bank_details.collected, plus pass-through events bridged from Billie (the Billie type/data are forwarded verbatim and are not constrained to the catalogue union). The other catalogue events are not produced by live code.
  • Live delivery gap. At 8fca443c8 the outbound worker claims and dispatches only mode = 'sandbox' rows. Live and Billie rows are enqueued but not delivered. A live partner should not expect to receive webhooks at this build (known gap, flagged for the service team; sandbox delivery is unaffected).

No runtime payload validation. The outbound publisher sends whatever type/data the emitting code supplies; it does not validate against the schema union at send time.

Known envelope deviations. api_version is defaulted to the date string "2026-05-10", not "v1"; some producers omit the base referral_id/context fields; bank_details.collected is emitted thin by both tiers (sandbox delivers only session_id + collected_at); and session.loa_signed omits referral_id/context/signed_by_full_name. Treat the base fields as optional-in-practice.

Signature verification #

Every webhook includes a signed timestamp + HMAC in the X-HelloBill-Signature header:

http
X-HelloBill-Signature: t=1715349660,v1=8a3f9c2b7e4d...
ComponentMeaning
t=<unix>Webhook generation time in Unix seconds. Used for replay defence.
v1=<hex>HMAC-SHA256(secret, "{t}.{raw_request_body}") as hex.

The signed payload is the literal string {t}.{raw_body}.

Replay protection

Reject any webhook whose t is older than 300 seconds (5 min tolerance) from your server's wall clock. This stops captured webhooks being replayed indefinitely. If your clock drift exceeds 5 min you have a bigger problem.

Key rotation

The v1= prefix is the scheme version. Future rotations introduce v2=... etc. During rotation, both signatures are included on the header — accept the request if either verifies. We give 30 days notice via X-HelloBill-Deprecation before retiring an old scheme.

typescript
import crypto from 'node:crypto';
import express from 'express';

const TOLERANCE_SECONDS = 300;

function verifyWebhook(rawBody: Buffer, header: string, secret: string): boolean {
  // Parse header: "t=1715349660,v1=8a3f..."
  const parts = Object.fromEntries(
    header.split(',').map(p => p.split('=', 2) as [string, string])
  );
  const t = parseInt(parts.t, 10);
  if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  // Iterate over all v* schemes present; accept if any verifies
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.`).update(rawBody)   // raw bytes — do NOT parse JSON first
    .digest('hex');

  for (const [k, v] of Object.entries(parts)) {
    if (k.startsWith('v') && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected))) {
      return true;
    }
  }
  return false;
}

app.post('/webhooks/hellobill',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-hellobill-signature'] as string;
    if (!verifyWebhook(req.body, sig, process.env.HELLOBILL_WEBHOOK_SECRET!)) {
      return res.status(401).json({ error: 'invalid_signature' });
    }
    const event = JSON.parse(req.body.toString());
    // deduplicate on event.id, then process
    res.json({ received: true });
  }
);
Critical

Compute HMAC over raw request body bytes, not over re-serialised JSON. Verify the timestamp before the HMAC — a malicious actor with stolen signature material would still need to win the timing window.

Retry schedule #

AttemptDelayTimeout
1Immediate10 s
230 s10 s
3 (final)5 min10 s

Failed events retained 30 days. Manual replay available in PartnerDock → Webhook Health.

Event reference #

Common envelope

FieldTypeAlways presentNotes
idstringyesUse as idempotency key
typestringyesPrimary event-type field — see table below. Use this field.
eventstringyesAlias for type — same value, emitted for backward compatibility with v1.10 consumers.
created_atstringyesISO 8601
api_versionstringyesWebhook payload schema version, e.g. "2026-05-10". Pin your handler to a known api_version; new fields may appear, existing ones won't change shape until a new api_version is announced.
livemodebooleanyestrue when environment === 'production', false otherwise.
environmentstringyes"production" | "sandbox"
data.session_idstringyes
data.referral_idstring | nullyes
data.contextContextEntry[]yesEcho of session context[]

Events (12 variants)

Event (type)Additional data fields
session.embed_opened
session.details_confirmedcorrections: string[]
session.products_selectedproducts: array of { product_id, category, sub_categories, location }
session.loa_signedloa_id, signed_at_server (authoritative server timestamp), signed_by_full_name
session.account_createdcustomer_id
session.app_installedcustomer_id, device_type: "ios"|"android", installed_at
session.app_deferredcustomer_id, deferred_reason
subscription.changedcustomer_id, previous_status, new_status, trial_ends_at?
setup_status.changedcustomer_id, product_id, setup_id, category, sub_categories, supplier_name, location, previous_status, new_status, max_retries_exceeded, details?
move_out_notification.sentcustomer_id, setup_id, product_id, category, supplier_name, location: "previous", notified_at, channel: "email"|"api"|"postal"
move_out_notification.failedcustomer_id, setup_id, product_id, category, supplier_name, location: "previous", failed_at, reason, retry_scheduled_at?
bank_details.collectedbank_details_id, account_number_ending, sort_code_masked, modulus_check_passed, collected_at
session.loa_signed uses signed_at_server

The session.loa_signed webhook payload uses signed_at_server (the authoritative server-stamped time), not signed_at. This is the timestamp that determines the LoA's 12-month legal validity window.

Example: bank_details.collected

json
{
  "type": "bank_details.collected",
  "event": "bank_details.collected",
  "id": "evt_01HX...",
  "created_at": "2026-05-19T10:00:01Z",
  "api_version": "2026-05-10",
  "livemode": false,
  "environment": "sandbox",
  "data": {
    "session_id": "ses_abc123",
    "referral_id": "REF-2026-05-10-abc123",
    "context": [],
    "bank_details_id": "bnk_xyz789",
    "account_number_ending": "4958",
    "sort_code_masked": "08-**-99",
    "modulus_check_passed": true,
    "collected_at": "2026-05-19T10:00:01Z"
  }
}
Disambiguating switch vs closure events

setup_status.changed fires for both current-address switches (location: 'current') and previous-address closure setups (location: 'previous'). Read data.location to know which. move_out_notification.sent / .failed fire only for previous-address closure notifications — they are emitted in addition to setup_status.changed for the same setup.

Error model #

Error envelope

json
{
  "error": {
    "type":       "invalid_request",
    "code":       "validation.failed",
    "message":    "addresses.current.postcode: String must contain at least 3 character(s)",
    "request_id": "req_01HX7G2K..."
  }
}
FieldAlways presentNotes
error.typeyesBroad category — see error.type enum below
error.codeyesMachine-readable specific code
error.messageyesHuman-readable description. For schema-validation failures this is the validation issues joined into one string.
error.request_idyesInclude in support requests (also returned as X-HelloBill-Request-Id)
error.paramnoReserved — present on selected errors only
error.missing_fieldsnoReserved — present on selected errors only
error.detailsnoOptional structured detail object — present on selected errors (e.g. customer.credit_check_history_insufficient carries details.min_years_required and details.provided_years)
Validation errors flatten to one message

Schema (Zod) validation failures return a single flattened message (issues joined with ; ) under code: "validation.failed". There is no per-field param/missing_fields array on validation errors in the current implementation — parse the message for human display, and validate client-side against the schemas in the Schema reference to avoid round-trips.

error.type categories

error.typeUsed for
invalid_requestSchema / request-shape rejections (4xx, mostly 400 — e.g. validation.failed, query.invalid_cursor)
validation_errorBusiness-rule rejections at account creation (400 — e.g. slot and credit-check rules)
invalid_credentialsAuthentication failures (401)
not_foundMissing / cross-partner resources (404)
conflictState conflicts other than idempotency (409 — e.g. LoA/bank-details lifecycle)
idempotency_conflictIdempotency-Key reused with a different body (409)
rate_limitedRate-limit rejections (429)
discovery_in_progressFirst-time property discovery still running (425 — Retry-After set)
mode_not_implementedlive_ request to an unimplemented backend (503)
internalUnexpected server errors (500/502)

Error code reference #

Error codes use a category.specific namespace. Match error.code.startsWith('loa.') for category-level handling without coupling to specific codes.

HTTPerror.codeerror.typeRetryableWhen
400validation.failedinvalid_requestnoRequest body/query fails schema validation
400loa.products_requiredvalidation_errornoGET /loa with empty/missing product_ids
400signature_image.invalidvalidation_errornoSignature image malformed or fails server checks (only error code for image issues; PNG/JPEG accepted, SVG is not)
400customer.products_requiredvalidation_errornoselected_products missing/empty on account creation
400customer.product_not_in_catalogvalidation_errornoA selected product_id is not available for this session
400customer.informational_product_not_selectablevalidation_errornoLive tier only (1.22). A selected product_id is an informational incumbent (is_incumbent: true) in energy or broadband and cannot be switched to. Carries param: "selected_products"; raised before any side-effect. Sandbox does not reproduce this. See Webhooks → tier divergences.
400customer.duplicate_productvalidation_errornoThe same product_id appears more than once in selected_products
400customer.missing_selected_slotvalidation_errornoA selected product requires a scheduling slot but none was supplied
400customer.unexpected_selected_slotvalidation_errornoA scheduling slot was supplied but no selected product requires one
400customer.missing_credit_check_addressesvalidation_errornoA selected product requires credit-check addresses but customer.credit_check.previous_addresses is empty/absent
400customer.credit_check_history_insufficientvalidation_errornoCredit-check history present but < 3 years / has gaps. Carries error.details.min_years_required and error.details.provided_years
400customer.unexpected_credit_check_addressesvalidation_errornoCredit-check addresses supplied but no selected product requires them
400query.invalid_cursorinvalid_requestnoPagination cursor is malformed or does not decode
401auth.invalid_credentialsinvalid_credentialsno (re-auth)Bad client credentials, or missing/malformed/expired Bearer token
404session.not_foundnot_foundnoSession ID not found for this partner (scoped — see resource scoping)
404customer.not_foundnot_foundnoCustomer ID not found
404loa.not_foundnot_foundnoloa_id not found for this session
404bank_details.not_foundnot_foundnoNo bank details collected for this session
404route.not_foundnot_foundnoUnknown route (method + path did not match any endpoint)
409idempotency.fingerprint_mismatchidempotency_conflictnoIdempotency-Key reused with a different request body
409loa.already_signedconflictnoLoA already signed with different attributes
409loa.selection_changedvalidation_erroryes (re-fetch + re-sign)Product selection changed since the LoA was issued — re-fetch GET /loa and re-sign
409bank_details.session_lockedconflictnoSession finalised; bank details can no longer be submitted
409customer.session_already_has_customerconflictnoSession already has a customer; a different body was submitted
409session.not_patchableconflictnoPATCH /partner/sessions/:id after the session is no longer patchable (customer created)
409bank_details.vaulted_resubmission_refusedconflictnoBank details already vaulted for this session; resubmission is refused
422loa.template_changedvalidation_erroryes (re-sign)signed_checksum no longer matches the current LoA text — re-fetch and re-sign
422bank_details.invalid_modulusvalidation_errornoAccount number fails the UK BACS modulus check
425property.discovery_in_progressdiscovery_in_progressyes (retry)Property discovery still running. Retry-After header set.
429rate.limitedrate_limitedyesRate limit hit. Retry-After header set — see Rate limits.
500internal.errorinternalyesTransient/unexpected server error
502internal.errorinternalyesUpstream (Keycloak) unreachable on POST /auth/partner/token
503mode.live_not_implementedmode_not_implementednolive_ request to a method/path absent from the global live route allowlist. As of 8fca443c8 the allowlist covers the full documented partner surface, so an authenticated live partner does not hit this in normal use.
503route.not_implementedmode_not_implementednoRoute exists but is not yet implemented for the request's mode
Type vs. status for LoA lifecycle errors

Two LoA errors describe "the LoA changed under you" but carry different error.type values, because they are distinct surfaces. loa.selection_changed (409) uses validation_error — the selected products no longer match the issued LoA, so the request is rejected as invalid against current state; re-fetch GET /loa and re-sign. loa.template_changed (422) also uses validation_error — the signed checksum no longer matches the current template text. Distinct from loa.already_signed (409, conflict), which is a hard state collision (the LoA is already signed). Match on error.code, not error.type, for these three.

Retry guidance

  • Retryable (425, 429, 500, 502): exponential backoff with jitter. Start 1 s, cap 60 s, max 5 attempts.
  • Re-auth (401): obtain a new token, retry once.
  • Re-fetch/re-sign (409 loa.selection_changed, 422 loa.template_changed): re-run GET /loa and re-capture the signature.
  • Non-retryable (other 4xx): fix the request before retrying.

Rate-limit headers #

http
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 43
X-RateLimit-Reset: 1715349660
X-HelloBill-Request-Id: req_01HX7G2K...

On 429, Retry-After: {seconds} is set.

Security & compliance #

Logging restrictions

Must NOT log: client_secret · access_token (full value) · session_token (full value) · customer.date_of_birth

Safe to log: session_id · customer_id · product_id · setup_id · error.request_id · referral_id · HTTP status codes · latency · endpoint paths

Credential security

CredentialWhere it livesMust NOT appear in
client_secretServer env varBrowser code, mobile bundles, git repos, logs
access_tokenServer memory (cached)Browser localStorage, logs
session_tokenPassed server→client onceLogs, analytics, third-party scripts
webhook_secretServer env varBrowser code, logs

LoA handling

Signed LoAs are stored as PDFs in S3 with the loa_id reference. Audit trail captured: signature method, IP address, user agent, signed_at. LoAs expire after 12 months and can be revoked by the customer at any time. Revoked or expired LoAs block any further switching or supplier communication.

Data retention

DataRetentionDeletion trigger
Customer PIIAccount duration + 7 years (UK Companies Act)Customer deletion request
Signed LoA PDFs7 yearsRegulatory minimum
Session data (pre-account session payload)30 days from creationAutomatic expiry — the session record is purged and session_id returns 404 thereafter
Webhook delivery logs30 daysAutomatic expiry
Audit logs7 years

Deletion requests processed within 30 days per UK GDPR Article 17.

Rate limits #

The API uses a single shared bucket per partner_org_id for all general requests, plus a separate higher-capacity bucket for the OAuth token endpoint.

BucketProductionSandboxScope
General requests (all endpoints except token)60 req/min300 req/minPer partner_org_id
POST /auth/partner/token1000 req/min1000 req/minPer client_id

The token endpoint limit is intentionally high to support rapid sandbox iteration (automated test suites that re-authenticate frequently). The general bucket is shared across all endpoints — there are no per-endpoint sub-limits in the current implementation.

force_refresh=true on GET /partner/sessions/:id/products is additionally throttled to 1 call per session per 5 minutes, independent of the general bucket.

Rate limit headers

Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. On 429, Retry-After: {seconds} is also set.

Sandbox & testing #

Key prefixes & isolation

PrefixDiscoveryEmailsSwitchesWebhooks
sb_Mock [4 energy, 6 broadband]SuppressedSimulated state machineReal payloads, "environment": "sandbox"
live_Real Electralink + water + councilRealReal switching chainProduction rows enqueued; not delivered at 8fca443c8 (delivery gap — see Webhooks callout)

Sandbox / production isolation contract

  • Separate ID realms. All resource IDs are scoped to environment. A session_id created under sb_ cannot be fetched with a live_ token (and vice versa) — the response is 404 session.not_found, identical to a non-existent ID, to prevent cross-environment enumeration.
  • Separate webhook endpoints. Sandbox and production each have their own webhook URL registration in PartnerDock.
  • Separate webhook secrets. A sandbox secret will not validate a production signature.
  • Separate rate limits. Sandbox limits (300 req/min) are independent of production limits (60 req/min).
  • No data flow between environments. Discovery cache, customer records, LoAs, audit logs are all environment-partitioned at rest.

A live_ token presenting an sb_-realm ID gets 404. A sb_ token presenting a live_-realm ID also gets 404. No special error code — the discrimination is silent to keep the boundary tight.

Test data

WhatValueExpected behaviour
addresses.current.postcode — full successSW1A 1AAAll categories return mock products at location: 'current'
addresses.current.postcode — partial failEC1A 1BBBroadband discovery fails; others succeed
addresses.current.postcode — all failZZ99 9ZZAll discovery fails; products: []
addresses.previous.postcode — successful move-outLS24 9PXReturns 2 mock incumbent products at location: 'previous' (energy + water)
addresses.previous.postcode — closure failureBS1 6XXMove-out notifications always fail (deterministic, for repeatable testing). Use to test move_out_notification.failed webhook.
move.out.move_out_date — too far futureany >180 d aheadReturns 400 move_out.invalid_date
final_reads[].reading — implausible99999999Returns 400 final_read.implausible
Email — existing sessiontest@hellobill.appReturns existing session (idempotency test)

Simulated switch lifecycle

text
T+0s   pending_setup    → in_progress
T+5s   in_progress      → awaiting_supplier
T+30s  awaiting_supplier→ confirmed (80%) or failed (20%)
T+40s  failed           → in_progress (auto-retry)
T+60s  in_progress      → confirmed (always succeeds on retry in sandbox)

Bank details test vectors

The sandbox recognises the following sort code + account number combinations for testing POST /partner/sessions/:id/bank-details. All other well-formed combinations return 422 bank_details.invalid_modulus unless they happen to pass the real Vocalink algorithm.

Sort codeAccount numberExpected result
08-99-9966374958Valid — modulus passes
10-79-9988837491Valid — modulus passes
20-29-5963748472Valid — modulus passes
93-80-6315764273Invalid — modulus fails (422 bank_details.invalid_modulus)
12-34-5612345Invalid — fails schema validation (400 validation.failed)

Simulated closure notification lifecycle

text
T+0s    pending_setup → in_progress
T+10s   in_progress   → notified  (90% — supplier accepts notification)
T+10s   in_progress   → failed    (10% — supplier rejects, e.g. account not found)
T+20s   failed        → in_progress (auto-retry)
T+30s   in_progress   → notified  (always succeeds on retry in sandbox)

Webhook testing

PartnerDock includes "Send Test Webhook" buttons per event type — fires a real signed payload to your registered endpoint before go-live.

Integration checklist #

Run this list before flipping sb_ to live_.

  • ☐ Receive client_id (sb_ prefix) + client_secret
  • npm install @hello-bill/node @hello-bill/sdk
  • ☐ Mount createHellobillRouter / createHellobillHandler (1 line)
  • ☐ Implement CSRF / Origin checks on the mount path; bind customer.email to your own session — don't trust sessionData.email blind
  • ☐ Write buildSessionPayload — populate context[] with available metadata
  • ☐ Capture GDPR consent on partner site before calling POST /sessions (sessionData bootstrap)
  • ☐ Mount HelloBill.init({ baseEndpoint, sessionData, mountTo }) (1 line)
  • (B2B integrations only) Supply customer.address when the account holder's address differs from addresses.current (otherwise omit and the server defaults it)
  • (Move-out journeys only) Supply addresses.previous AND move.out together; use move.out.notify.* flags to opt the customer in to per-service supplier notifications; supply move.out.final_reads[] when available (energy / water)
  • (Optional) Pass appearance to brand the embed; if your app has a theme picker, use theme: 'system' or call embed.updateAppearance(…) on theme change (Theming)
  • (Optional) Set locale: 'cy-GB' when serving Welsh-speaking customers
  • ☐ Register webhook endpoint + implement HMAC verification with timestamp check (t= within 5 min) and raw-body bytes
  • ☐ Pin webhook handler to a known api_version
  • ☐ Deduplicate webhooks using id
  • ☐ Handle session.loa_signed, setup_status.changed, subscription.changed
  • (Move-out journeys only) Handle move_out_notification.sent and move_out_notification.failed; read data.location on setup_status.changed to disambiguate current vs previous address
  • ☐ Verify provided_fields matches expected skips (paths now use addresses.current.* / move.in.* / addresses.previous.* / move.out.*)
  • ☐ Test: SW1A 1AA · EC1A 1BB · ZZ99 9ZZ · LS24 9PX (previous-address success) · BS1 6XX (closure failure)
  • ☐ Switch to live_ credentials after HelloBill approval

Schema reference #

Complete TypeScript type definitions for all request/response shapes. These types are exported from @hello-bill/sdk and @hello-bill/node.

Session payload #

typescript
interface SessionPayload {
  referral_id?: string;
  customer:    CustomerObject;       // required
  occupants?:  OccupantObject[];
  addresses:   AddressesObject;      // required — grouped current + optional previous
  move:        MoveObject;           // required — grouped in (required) + out (optional)
  meters?:     MetersObject;         // optional — current-address meter identifiers
  consent:     ConsentObject;        // required
  context?:    ContextEntry[];       // optional — max 50 entries
}

Session response #

typescript
interface SessionResponse {
  session_id:               string;
  session_token:            string;          // HS256 JWT, 7200 s TTL at 8fca443c8 (SANDBOX_SESSION_TOKEN_TTL_SEC)
  embed_base_url:           string;          // embed ORIGIN only; SDK composes {embed_base_url}/{version}/onboard?token=...
  discovery_status:         'running' | 'complete';
  estimated_ready_seconds?: number;          // omitted when discovery_status is 'complete'
  provided_fields:          string[];        // dot-notation paths
  expires_at:               string;          // ISO 8601, 30 days from creation
  gmaps_key?:               string;          // Google Maps Embed API key; SDK forwards to iframe
}

Address types #

typescript
interface AddressObject {
  address_line_1: string;            // required
  address_line_2?: string;
  address_line_3?: string;           // optional — rural / multi-building / industrial estate
  city:            string;           // required
  postcode:        string;           // required, UK format
  uprn?:           string;           // canonical when supplied
  country?:        'GB';             // reserved for future expansion
}

interface PropertyObject extends AddressObject {
  bedrooms?:        number;
  number_occupants?: number;
  property_type?:   PropertyType;
  epc_rating?:      'A'|'B'|'C'|'D'|'E'|'F'|'G';
  heating_type?:    HeatingType;
}
type PropertyType = 'detached'|'semi_detached'|'terraced'|'flat'|'bungalow'|'maisonette'|'other';
type HeatingType  = 'gas_central'|'electric'|'oil'|'heat_pump'|'district'|'solid_fuel'|'other';

interface AddressesObject {
  current:   PropertyObject;
  previous?: PropertyObject;
}

// Returned by GET /partner/sessions/:id/property
type CollectionDay = 'monday'|'tuesday'|'wednesday'|'thursday'|'friday'|'saturday'|'sunday';
type BinType = 'general_waste'|'recycling'|'food_waste'|'garden_waste'|'glass';
interface BinCollectionPayload {
  primary_collection_day:      CollectionDay;
  primary_collection_types:    BinType[];
  collection_frequency:        'weekly'|'fortnightly'|'monthly';
  next_collection_date:        string;     // ISO date
  days_after_move_in:          number;     // int >= 0
  reminder_eve_label:          string;
  secondary_collection_day?:   CollectionDay;
  secondary_collection_types?: BinType[];
}
interface BroadbandAvailability {
  max_down_speed_mbps?: number;   // >= 0
  max_up_speed_mbps?:   number;   // >= 0
}
interface PropertyDetailResponse {
  session_id:        string;
  property:          PropertyDetailObject;   // may be {} when data_completeness === 'empty'
  data_completeness: 'complete' | 'partial' | 'empty';
  extracted_at:      string;
  expires_at:        string;
  broadband?:        BroadbandAvailability;  // added 1.16
}

interface PropertyDetailObject {
  property_type_code?:               PropertyTypeCode;
  property_type_subcode?:            PropertyTypeSubcode;
  building_type?:                    BuildingType;
  building_style?:                   BuildingStyle;
  number_of_bedrooms?:               number;
  number_of_bathrooms?:              number;
  floor_number?:                     number;
  energy_performance_rating?:        'A'|'B'|'C'|'D'|'E'|'F'|'G';
  has_parking?:                      boolean;
  property_has_off_road_parking?:    boolean;
  property_has_underground_parking?: boolean;
  property_has_a_driveway?:          boolean;
  bin_collection?:                   BinCollectionPayload;  // added 1.16
}
type PropertyTypeCode    = 'house'|'flat'|'maisonette'|'bungalow'|'other';
type PropertyTypeSubcode = 'detached'|'semi_detached'|'terraced'|'end_terrace'|'purpose_built_flat'|'converted_flat'|'studio'|'penthouse'|'other';
type BuildingType        = 'residential'|'mixed_use'|'commercial'|'unknown';
type BuildingStyle       = 'victorian'|'edwardian'|'georgian'|'interwar'|'post_war'|'modern'|'new_build'|'period'|'unknown';
type LocationRole        = 'current' | 'previous';

Customer & occupant #

typescript
interface CustomerObject {
  type:                CustomerType;
  title:               Title;          // 1.19 — required
  other_title?:        string;         // 1.19 — required iff title === 'other'
  email:               string;
  first_name?:         string;         // 1.19 — required unless type === 'management_company'
  last_name?:          string;
  full_legal_name?:    string;         // 1.19 — required iff type === 'management_company'; recommended otherwise
  phone?:              string;
  date_of_birth?:      string;         // YYYY-MM-DD
  is_primary_occupant?: boolean;       // default: true
  on_psr?:             boolean;
  address?:            AddressObject;   // optional — defaults to addresses.current
  credit_check?:       CreditCheckObject; // 1.19 — residence history for credit-checked products
}
type Title = 'mr'|'mrs'|'miss'|'ms'|'mx'|'dr'|'other';   // 1.19
type CustomerType = 'owner_occupier'|'tenant'|'licensee'|'lodger'|'management_company'|'other';

interface CreditCheckObject {                              // 1.19
  previous_addresses?: CreditCheckAddress[];               // max 12, most recent first
}
interface CreditCheckAddress extends AddressObject {       // 1.19
  move_in_date:        string;         // YYYY-MM-DD, <= today
  move_out_date?:      string;         // omit = current residence; exactly one entry may omit it
}

interface OccupantObject {
  type:                OccupantType;
  title?:              Title;          // 1.19 — optional
  other_title?:        string;         // 1.19 — required iff title === 'other'
  first_name?:         string;
  last_name?:          string;
  full_legal_name?:    string;         // 1.19 — optional; may be a company name
  email?:              string;
  phone?:              string;
  is_primary_occupant?: boolean;
}
type OccupantType = 'owner'|'tenant'|'licensee'|'lodger'|'guarantor'|'other';

Move objects #

typescript
interface MoveObject {
  in:   MoveInObject;
  out?: MoveOutObject;
}

interface MoveInObject {
  move_in_date:         string;          // required, YYYY-MM-DD
  bill_type?:           'single'|'split'; // default: 'single'
  rent_pcm?:            number;           // integer GBP/month
  tenancy_start_date?:  string;
  tenancy_end_date?:    string;
}

interface MoveOutObject {
  move_out_date:        string;          // required, YYYY-MM-DD
  tenancy_end_date?:    string;
  reason?:              MoveOutReason;
  forwarding_address?:  AddressObject;
  notify?: {
    energy?:        boolean;
    water?:         boolean;
    council_tax?:   boolean;
    tv_licence?:    boolean;
    broadband?:     boolean;
    mobile?:        boolean;
  };
  final_reads?:         FinalReadObject[];
}
type MoveOutReason = 'end_of_tenancy'|'sale'|'transfer'|'bereavement'|'other';

interface FinalReadObject {
  category:              'energy'|'water';
  fuel?:                 'electricity'|'gas';     // only for energy
  meter_serial_number?:  string;
  reading:               number;
  read_date:             string;                   // YYYY-MM-DD
  photo_data_base64?:    string;                   // PNG, max 500 KB decoded
  estimated?:            boolean;
}

interface MetersObject {
  mpan?: string;
  mprn?: string;
  current_supplier_electricity?: string;
  current_supplier_gas?: string;
}

interface ConsentObject {
  data_sharing_accepted:    true;
  data_sharing_accepted_at: string;
  marketing_accepted?:      boolean;
}

type ContextEntry =
  | { name: string; type: 'string';  value: string  }
  | { name: string; type: 'number';  value: number  }
  | { name: string; type: 'boolean'; value: boolean };

Products #

typescript
type Category =
  | 'energy' | 'water' | 'broadband' | 'council_tax' | 'mobile' | 'tv_licence'
  | 'home_insurance' | 'breakdown_cover';

interface ProductBase {
  product_id:          string;          // prq_
  category:            Category;
  sub_categories:      string[];
  supplier_name:       string;
  supplier_logo_url?:  string | null;
  location:            LocationRole;    // 'current' | 'previous' — server-derived
  is_incumbent:        boolean;
  monthly_amount_pence?: number;
  annual_amount_pence?:  number;
  confidence:          'high'|'medium'|'low';
  tariff_name?:        string | null;
  note?:               string;
  extracted_at:        string;
  expires_at:          string;
}

type CacheStatus = 'fresh' | 'partial' | 'throttled';   // 'cached' is not emitted

interface ProductsResponse {
  session_id:                string;
  products:                  Product[];
  products_complete:         boolean;
  cache_status:              CacheStatus;
  total_suppliers_queried:   number;
  total_suppliers_responded: number;
  next_cursor:               string | null;
}

Incumbent pagination: Products with is_incumbent: true are always returned on page 1 across both current and previous locations, and they do not count against the limit query parameter. This guarantees you always see what the household pays today, regardless of pagination.

typescript
interface ProductSetup {
  product_id:           string;
  setup_id:             string;
  category:             string;
  sub_categories:       string[];
  supplier_name:        string;
  location:             LocationRole;
  status:               SetupStatus;
  status_updated_at:    string;
  max_retries_exceeded: boolean;
  details?: {
    account_number?:     string;
    switch_date?:        string;
    direct_debit_setup?: boolean;
    notified_at?:        string;
    channel?:            'email'|'api'|'postal';
    final_reads_submitted?: boolean;
  };
  expected_resolution_at?: string;
}

type SetupStatus =
  | 'pending_setup' | 'in_progress' | 'awaiting_supplier'
  | 'confirmed'     // location: 'current' only
  | 'notified'      // location: 'previous' only
  | 'acknowledged'  // location: 'previous' only
  | 'failed';

LoA types #

typescript
interface LoaRecord {
  loa_id:              string;
  loa_text_url:        string;         // Bearer-gated PDF URL — proxy server-side only
  loa_text_html_url:   string;         // (1.17) session-token-gated document URL → GET /api/v1/partner/loa/:id/document → { body_html, checksum }
  covers_product_ids:  string[];
  checksum:            string;         // SHA-256 of LoA body — submit as signed_checksum
  expires_at:          string;
  version:             string;         // e.g. 'loa-template-v4'
}

interface LoaListResponse {
  session_id: string;
  loas:       LoaRecord[];
}

interface SignatureImage {
  mime_type:   'image/png' | 'image/jpeg';  // SVG is not accepted
  data_base64: string;                       // base64 payload only (strip data: URI prefix)
  width?:      number;                       // 1 <= w <= 2000
  height?:     number;                       // 1 <= h <= 1000
}

interface LoaSignature {   // used in POST /loa body and in loa_signatures[] on /customers
  loa_id:              string;
  signed_checksum:     string;
  signed_at_client:    string;   // ISO 8601 datetime — advisory audit metadata only
  signed_by_full_name: string;   // max 200 chars
  signature_image:     SignatureImage;
  ip_address?:         string;
  user_agent?:         string;   // max 500 chars
}

interface SubmitLoaResponse {
  loa_id:              string;
  signed_at_server:    string;   // authoritative UTC timestamp — determines 12-month validity
  signed_by_full_name: string;
}

Bank details types #

typescript
interface BankDetailsRequest {
  sort_code: string;           // 6 digits, may include hyphens/spaces
  account_number: string;      // 6-10 digits
  account_holder_name: string;
  consent: BankDetailsConsent;
}

interface BankDetailsConsent {
  direct_debit_authorised: true;
  authorised_at: string;       // ISO-8601
  consent_text_version: string;
}

interface BankDetailsObject {
  id: string;
  account_number_ending: string; // last 4 digits
  sort_code_masked: string;      // format "NN-**-NN"
  account_holder_name: string;
  bank_name?: string;            // best-effort lookup; may be null
  modulus_check_passed: boolean;
  status: 'collected' | 'pending' | 'failed';
  collected_at: string;          // ISO-8601
  consent: BankDetailsConsent;
}

interface BankDetailsResponse {
  session_id: string;
  bank_details: BankDetailsObject;
  expires_at: string;            // ISO-8601
}

Status response #

typescript
type OverallStatus = 'pending' | 'in_progress' | 'confirmed' | 'partial' | 'failed';
type MoveOutStatus = 'pending' | 'in_progress' | 'notified' | 'partial' | 'failed' | null;
type SubscriptionStatus = 'not_subscribed'|'trialing'|'active'|'past_due'|'cancelled';

type IsoDateTime = string;
type CustomerId  = string;

interface SavingsSummaryResponse {  // (1.17) — also available via GET /partner/customers/:id/savings-summary
  customer_id:          string;
  annual_savings_pence: number;   // realized savings; 0 until setups confirm
  time_saved_minutes:   number;
  services_set_up:      number;   // count of confirmed setups at snapshot time
  extracted_at:         IsoDateTime;
}

interface CustomerStatusResponse {
  customer_id:         string;
  overall_status:      OverallStatus;
  move_out_status:     MoveOutStatus;    // null when no move.out
  subscription_status: SubscriptionStatus;
  trial_ends_at:       IsoDateTime | null;
  products: Array<ProductSetup & {
    last_updated_at: IsoDateTime;
  }>;
  last_updated_at:     IsoDateTime;
  savings_summary:     SavingsSummaryResponse | null;  // (1.17) null pre-terminal; populated once overall_status === 'confirmed'
}

interface CustomerCreateResponse {
  customer_id:       CustomerId;
  interstitial_url?: string;          // optional — handoff page to install the app
  app_deeplinks?: {
    ios?:      string;
    android?:  string;
    universal: string;
  };
  magic_link_sent:   boolean;   // 1.21 — intent only; link is SENT after bank-details submit
  loa_required:      boolean;         // added 1.16: true when an LoA signature is still outstanding
  product_setup: {
    initiated: number;
    products:  ProductSetup[];
  };
}

Webhook types #

typescript
interface WebhookEnvelope {
  id:           string;
  type:         string;          // primary event-type field
  event:        string;          // alias for `type` — same value, for v1.10 back-compat
  created_at:   string;
  api_version:  string;
  livemode:     boolean;         // true when environment === 'production'
  environment:  'production' | 'sandbox';
  data: {
    session_id:  string;
    referral_id: string | null;
    context:     ContextEntry[];
    [key: string]: unknown;
  };
}

type WebhookEvent = WebhookEnvelope & (
  | { type: 'session.embed_opened';
      data: WebhookEnvelope['data'] }
  | { type: 'session.details_confirmed';
      data: WebhookEnvelope['data'] & { corrections: string[] } }
  | { type: 'session.products_selected';
      data: WebhookEnvelope['data'] & {
              products: Array<{ product_id: string; category: Category;
                                sub_categories: string[]; location: LocationRole }> } }
  | { type: 'session.loa_signed';
      data: WebhookEnvelope['data'] & {
              loa_id: string; signed_at_server: IsoDateTime; signed_by_full_name: string } }
  | { type: 'session.account_created';
      data: WebhookEnvelope['data'] & { customer_id: CustomerId } }
  | { type: 'session.app_installed';
      data: WebhookEnvelope['data'] & {
              customer_id: CustomerId; device_type: 'ios'|'android';
              installed_at: IsoDateTime } }
  | { type: 'session.app_deferred';
      data: WebhookEnvelope['data'] & {
              customer_id: CustomerId; deferred_reason: string } }
  | { type: 'subscription.changed';
      data: WebhookEnvelope['data'] & {
              customer_id: CustomerId; previous_status: SubscriptionStatus | null;
              new_status: SubscriptionStatus; trial_ends_at: IsoDateTime | null } }
  | { type: 'setup_status.changed';
      data: WebhookEnvelope['data'] & {
              customer_id: CustomerId; product_id: string; setup_id: string;
              category: Category; sub_categories: string[]; supplier_name: string;
              location: LocationRole; previous_status: SetupStatus; new_status: SetupStatus;
              max_retries_exceeded: boolean; details?: ProductSetup['details'] } }
  | { type: 'move_out_notification.sent';
      data: WebhookEnvelope['data'] & {
              customer_id: CustomerId; setup_id: string; product_id: string;
              category: Category; supplier_name: string; location: 'previous';
              notified_at: IsoDateTime; channel: 'email'|'api'|'postal' } }
  | { type: 'move_out_notification.failed';
      data: WebhookEnvelope['data'] & {
              customer_id: CustomerId; setup_id: string; product_id: string;
              category: Category; supplier_name: string; location: 'previous';
              failed_at: IsoDateTime; reason: string; retry_scheduled_at?: IsoDateTime } }
  | { type: 'bank_details.collected';
      data: WebhookEnvelope['data'] & {
              bank_details_id: string; account_number_ending: string;
              sort_code_masked: string; modulus_check_passed: boolean;
              collected_at: IsoDateTime } }
);

Error types #

typescript
type ErrorType =
  | 'invalid_request' | 'validation_error' | 'invalid_credentials' | 'not_found'
  | 'conflict' | 'idempotency_conflict' | 'rate_limited' | 'discovery_in_progress'
  | 'mode_not_implemented' | 'internal';

type ErrorCode =
  | 'validation.failed'
  | 'loa.products_required'
  | 'signature_image.invalid'
  | 'customer.products_required'
  | 'customer.product_not_in_catalog'
  | 'customer.informational_product_not_selectable' // live tier only (1.22); energy/broadband incumbents
  | 'auth.invalid_credentials'
  | 'session.not_found'
  | 'customer.not_found'
  | 'loa.not_found'
  | 'bank_details.not_found'
  | 'idempotency.fingerprint_mismatch'
  | 'loa.already_signed'
  | 'loa.selection_changed'
  | 'bank_details.session_locked'
  | 'customer.session_already_has_customer'
  | 'loa.template_changed'
  | 'bank_details.invalid_modulus'
  | 'property.discovery_in_progress'
  | 'rate.limited'
  | 'internal.error'
  | 'mode.live_not_implemented';

interface ErrorEnvelope {
  error: {
    type:            ErrorType;
    code:            ErrorCode;
    message:         string;
    request_id:      string;
    param?:          string;
    missing_fields?: string[];
    details?:        Record<string, unknown>;
  };
}

Embed SDK types #

typescript
type ThemePreset = 'hellobill' | 'light' | 'dark' | 'system';
type Locale      = 'auto' | 'en' | 'en-GB' | 'cy-GB';

interface AppearanceObject {
  theme?:             ThemePreset;
  disableAnimations?: boolean;
  variables?:         AppearanceVariables;
  rules?:             AppearanceRules;
}

interface AppearanceVariables {
  colorPrimary?: string;        colorOnPrimary?: string;
  colorBackground?: string;     colorSurface?: string;
  colorBorder?: string;         colorText?: string;
  colorTextSecondary?: string;  colorTextPlaceholder?: string;
  colorTextDisabled?: string;   colorDanger?: string;
  colorSuccess?: string;        colorWarning?: string;
  iconColor?: string;           iconHoverColor?: string;
  iconCheckmarkColor?: string;  fontFamily?: string;
  fontSizeBase?: string;        fontLineHeight?: string;
  fontWeightNormal?: string;    fontWeightMedium?: string;
  fontWeightBold?: string;      spacingUnit?: string;
  gridRowSpacing?: string;      gridColumnSpacing?: string;
  borderRadius?: string;        buttonBorderRadius?: string;
  focusBoxShadow?: string;      focusOutline?: string;
}

type AppearanceSelector =
  | '.HBLabel'
  | '.HBInput' | '.HBInput:focus' | '.HBInput:hover'
  | '.HBInput--invalid' | '.HBInput::placeholder'
  | '.HBButton' | '.HBButton:hover' | '.HBButton:focus' | '.HBButton:disabled'
  | '.HBCard' | '.HBCard--selected' | '.HBCard:hover'
  | '.HBCheckbox' | '.HBCheckboxLabel' | '.HBCheckboxInput'
  | '.HBRadioIcon' | '.HBRadioIconOuter' | '.HBRadioIconInner'
  | '.HBTab' | '.HBTab:hover' | '.HBTab--selected'
  | '.HBAccordionItem' | '.HBError' | '.HBMenu' | '.HBMenuAction';

type AppearanceRules = Partial<Record<AppearanceSelector, Record<string, string>>>;

type LifecycleEventType =
  | 'ready' | 'session.created' | 'consent.granted' | 'screen.shown'
  | 'loa.signed' | 'account.creating' | 'account.created'
  | 'error' | 'complete' | 'close'
  | 'journey.completed' | 'journey.closed'
  | 'operational.iframe_timeout' | 'operational.tier_selected';   // 2.4.0 — tier auto-detect signal

interface CompletionResult {
  customer_id:          string;
  selected_products:    { product_id: string; location: 'current' | 'previous' }[];
  loa_signed_at?:       string;
  setup_status?:        OverallStatus;
  move_out_status?:     MoveOutStatus | null;
}

interface InitOptions {
  baseEndpoint:     string;
  mountTo:          string | HTMLElement;
  sessionData:      SessionData;
  embedOrigin?:     string;          // dev/UAT override only — ignored when NODE_ENV==='production' (prod uses the API's embed_base_url)
  tier?:            'live' | 'sandbox';  // 2.4.0 — optional pin; omitted = auto-detect from the session (see § Tier selection)
  locale?:          Locale;
  appearance?:      AppearanceObject;
  integrations?:    { gmapsKey?: string };
  onComplete?:      (result: CompletionResult) => void;
  onClose?:         (reason: 'user_dismissed' | 'timeout' | 'error') => void;
  onEvent?:         (event: { type: LifecycleEventType; data: Record<string, unknown> }) => void;
  onUserAction?:    (action: { type: string; data: Record<string, unknown> }) => void;
  onError?:         (error: HelloBillInitError) => void;   // 1.21 — init() failed to obtain a session
}

interface HelloBillInitError {
  kind:       'validation' | 'auth' | 'conflict' | 'rate_limited' | 'server' | 'network' | 'malformed';
  status?:    number;
  code?:      string;
  message:    string;
  retryable:  boolean;
}

interface EmbedHandle {
  updateAppearance(patch: Partial<AppearanceObject>): void;
  setLocale(locale: Locale): void;
  destroy(): void;
}

onError & HelloBillInitError (1.21)

onError fires when HelloBill.init() fails to obtain a session (session-create failure, invalid/expired token, CDN unreachable). The same failure is also surfaced via onEvent({ type: 'error' }) and a hellobill:init-failed window event; init() additionally rejects for await callers. Wire onError and show a retry UI for retryable errors.

kindTriggerretryable
validation4xx input (400/402/422/…)false
auth401 / 403 (invalid/expired creds, forbidden)false
conflict409 (session/resource conflict)false
rate_limited429true
server5xxtrue
networkfetch rejected before a responsetrue
malformed2xx with an unusable bodyfalse
GET /partner/customers — removed in 1.13

GET /api/v1/partner/customers (list) was removed in Revision 1.13. The endpoint returns 404 on all environments. Use GET /partner/customers/:id/status to poll per-customer setup state. Webhooks can push status changes on the sandbox tier, but at 8fca443c8 live webhook rows are not delivered (see the Webhooks delivery gap) — live integrations must poll the status endpoint. Partners track customer IDs themselves via the customer_id returned from POST /partner/sessions/:id/customers.

Versioning #

  • Current: /api/v1/
  • Breaking changes → /api/v2/ with 12-month /api/v1/ overlap
  • Non-breaking additions deployed without version bump
  • Deprecation: 90 days notice via email + X-HelloBill-Deprecation response header

expires_at reference

Several fields named expires_at (or expires_in) appear in this guide with different semantics. Keep them straight:

FieldObjectDurationMeaning
expires_inToken response300 sBearer access_token lifetime — refresh before this
expires_atSession response30 daysSession record usability — after this the session_id returns 404 and the record is purged. Changed in 1.16 (was 90 days).
expires_atProduct24 hproduct_id validity
expires_atLoA template~30 daysLoA template expiry
signed_at_server + 12 moLoA signature12 monthsSigned LoA legal validity — signed_at_server is the authoritative server timestamp
Idempotency-Key TTLHeader24 hSame key + same request → same response
force_refresh throttlePer session5 minCooldown between forced cache busts

Changelog #

Partner-facing highlights since Revision 1.11. For the full internal spec changelog, see the canonical spec document.

Revision 1.22 — 2026-07-22

Documentation-reconciliation release against backend build hellobill-partner-api:8fca443c8 (pinned). No code behaviour changed — the guide is brought into line with behaviour already shipped, including several sandbox/live tier divergences that earlier revisions did not surface. Mirrors the canonical spec Revision 1.22.

  • Error code customer.informational_product_not_selectable documentedPOST /customers returns 400 (validation_error, param: selected_products) when a selected product is an informational incumbent (is_incumbent: true) in energy or broadband. Raised on the live tier only, before any side-effect; sandbox does not reproduce it.
  • Webhook per-tier emission & live delivery gap — the event catalogue is the shared schema; sandbox enqueues a per-scenario subset (delivered), while live enqueues session.embed_opened, session.loa_signed, account.created (that literal wire type, not session.account_created), bank_details.collected, plus Billie pass-through. At 8fca443c8 the outbound worker dispatches only sandbox rows, so live webhooks are not delivered. The publisher does not runtime-validate payloads. Known envelope deviations documented (api_version defaults "2026-05-10"; some producers omit referral_id/context; thin bank_details.collected and session.loa_signed).
  • 503 mode.live_not_implemented clarified — gated by a single global live route allowlist (no per-partner entitlement); at 8fca443c8 the allowlist covers the full documented surface.
  • Session-token scope clarified — session tokens are minted on both tiers; the protected partner router accepts them only as a sandbox-mode fallback for the credit-check PATCH and snapshot, while the LoA-document and telemetry routes accept sandbox or live; resume and other partner calls require the M2M access token.
  • Session-token TTL corrected to 7200 s (SANDBOX_SESSION_TOKEN_TTL_SEC) — earlier revisions quoted 300 s, which does not match the shipped default. The partner access token (300 s) and webhook replay window (300 s) are unchanged. The session_token.expired error code referenced by earlier revisions is not emitted — an expired session token falls through to 401 auth.invalid_credentials.
  • Live POST /resume returns the sandbox embed_base_url (known gap) and requires the M2M access token.

Revision 1.21.2 — 2026-07-15 (SDK 2.4.0)

SDK-side release; no Partner API contract change. Additive for all integrations.

  • Tier auto-detection — the npm @hello-bill/sdk no longer ships sandbox-pinned. Unpinned integrations adopt the tier of the session's embed_base_url and announce it via console.info and a new operational.tier_selected lifecycle event ({ tier, source: 'session' }). Live sessions now mount without the undocumented globalThis.__HB_TIER__ workaround (which keeps working for existing integrations). See § Tier selection.
  • tier init optioninit({ tier: 'live' | 'sandbox' }) (and resume) pins the integration; a cross-tier session fails fast with an embedOrigin tier mismatch error naming the remedy.
  • LifecycleEventType reference updated — the schema reference now lists the full event set, adding the previously undocumented journey.completed, journey.closed, and operational.iframe_timeout alongside the new operational.tier_selected.

Revision 1.21 — 2026-07-06

Folds in the Rev 1.21 addendum (embed flags & error model) and reconciles the guide with the deployed build. One breaking ordering change (bank details are now customer-first); the rest is additive.

  • BREAKING (ordering): bank details are customer-firstPOST /sessions/:id/bank-details now requires an existing customer (404 customer.not_found otherwise). Create the customer first, then submit bank details.
  • Magic link + switching deferred to bank-details success — creating the customer records intent (magic_link_sent) but does not send the link or start switching; those fire once bank details vault (at-most-once).
  • feature_flags in the session responsedirect_debit, loa_signing, allow_removal_services, allow_electoral_roll, priority_supplier_name; snapshotted at creation.
  • journey_type in the session responsemove_in | move.
  • Session save/restore — new POST /sessions/:id/snapshot and POST /sessions/:id/resume persist and restore the embed's in-flight progress.
  • SDK onError + HelloBillInitErrorinit() failures now surface a typed error (kind: validation | auth | conflict | rate_limited | server | network | malformed) with a retryable flag, via onError, onEvent 'error', a hellobill:init-failed window event, and a rejected init() promise.
  • No-JS alias /v1/onboard — documented as the stable mutable direct-link path (dev/UAT overwrite in place; prod version-pinned).

Revision 1.20 — 2026-06-24

Non-breaking for correctly-integrated embed partners. The product_id field was always documented as opaque; partners were explicitly instructed not to parse or correlate it.

  • BREAKING (by spec): product_id format changed to prq_<ULID> — product IDs now use the opaque, type-prefixed ULID format (prq_<ULID>) instead of the Luhn-checked PR-NNNN-NNNN-C format. Affects: the product_id field in onComplete selected_products[], webhook payloads, and LoA coverage. Partners who followed the documented contract (treat product_id as an opaque string; do not parse or correlate it) are unaffected. Partners who matched against the PR- prefix or the Luhn pattern must remove that assumption.
  • TypeScript schema reference updated — the ProductBase.product_id comment now reflects prq_<ULID>.

Revision 1.19 — June 2026

Breaking changes for all partners: customer.title is now mandatory, and customer.first_name / customer.full_legal_name are now conditionally required by customer.type. HelloBill is sandbox/development-stage; there is no stable-interface guarantee yet.

  • BREAKING: customer.title is now required — enum mr | mrs | miss | ms | mx | dr | other. When other, supply customer.other_title (free text). occupants[].title is added (optional, same enum).
  • customer.full_legal_name + occupants[].full_legal_name — new optional fields; may be a company name. Required for management_company customers; recommended for all.
  • BREAKING: conditional name validationcustomer.first_name is required for all customer.type values except management_company; customer.full_legal_name is required for management_company.
  • Energy credit-check addresses — new customer.credit_check.previous_addresses[] (each an address + move_in_date + optional move_out_date). Energy products may carry requires_credit_check_addresses: true; the embed then shows a credit-check address screen and a ≥ 3-year history is enforced at account creation. See § Credit-check address screen.
  • PATCH /partner/sessions/:id — new endpoint; the embed uses it to persist the credit-check history collected after product selection.
  • Validation details[] — the validation.failed error now carries a machine-readable details[] array ({ code, path }) alongside the human-readable message.
  • Removals (AnyVan) temporarily disabled in sandbox — no removals products are returned, so the embed does not show the removals slot-picker screen. The mechanism is unchanged and re-enables when removals returns.

Revision 1.18 — June 2026

Breaking change for partners consuming embed_url directly. SDK-mount partners are unaffected.

  • BREAKING: embed_urlembed_base_url — the POST /sessions response field is renamed. The value is now the embed origin only (e.g. https://embed-sandbox.hellobill.app), with no path or token. The SDK composes the full iframe URL as {embed_base_url}/{sdk_version}/onboard?token={session_token}. Partners must not construct the iframe URL themselves.
  • API-authoritative embed origin + SDK-composed version path — the origin comes from the API response (embed_base_url); the version path segment is pinned into the SDK build. This separates origin authority (API) from version pinning (SDK build).
  • Embed host → embed-sandbox.hellobill.app — the SDK script, iframe origin, and CSP directives now reference https://embed-sandbox.hellobill.app.
  • embedOrigin now enforced — the InitOptions.embedOrigin option is honored only when NODE_ENV !== 'production'; in production builds it is ignored and the compiled default is used. When honored, the value is validated against the compiled origin allowlist.
  • M1/M2 security scaffolds documented — M1 (tier pinning: SDK build compiled for a tier, rejects mismatched origins) and M2 (token-tier binding: session token carries a tier/mode claim, embed refuses to mount on mismatch) are now documented. Both surface as onEvent 'error'; no partner action required.

Revision 1.17 — June 2026

Additive release. No breaking changes for partners on 1.16.

  • Service slots (removals) — the embed now fetches schedulable slots via GET /partner/sessions/:id/service-slots?service=removals&provider=any_van (replaces the unbuilt anyvan-slots route). Products that require slot scheduling carry requires_slot_scheduling: true and slot_service: 'removals'. The chosen slot is submitted as a per-product selected_products[].selected_slot object on account creation.
  • LoA document endpoint — the embed fetches the LoA HTML for signing via GET /api/v1/partner/loa/:id/document, which returns { body_html, checksum }. Auth is the embed session_token Bearer with scope loa:read. The embed sanitises body_html before rendering. loa_text_html_url on the LoaRecord now points to this endpoint.
  • Savings summary — the embed success screen shows realized savings tiles ("£X/yr" + services set up), populated from the new savings_summary field on the customer status response (null pre-terminal, populated once overall_status === 'confirmed'). The embed polls status to terminal before showing the tiles, reading savings_summary inline; if it is null it falls back to GET /partner/customers/:id/savings-summary, and shows placeholders (--) if neither is available yet. Also available as a standalone resource via that endpoint.
  • Schema reference updated: SavingsSummaryResponse type added; CustomerStatusResponse gains savings_summary: SavingsSummaryResponse | null; LoaRecord.loa_text_html_url comment updated to reflect the document endpoint.

Revision 1.16 — May 2026

Reconciliation release bringing the specification into 1:1 alignment with the shipped implementation. Several contract clarifications and one corrected lifetime.

  • New endpoint: POST /partner/sessions/:id/loa — submit a single signed Letter of Authority. loa_signatures[] on POST /customers is now optional; submit via either path. Account-creation response adds loa_required: boolean.
  • Per-environment base URLs — Production partnerapi.hellobill.app, UAT, Dev. Mode is determined by the client_id prefix, not the host.
  • gmaps_key added to the POST /sessions response (forwarded by the SDK to the embed as integrations.gmapsKey).
  • Session record lifetime set to 30 days (was documented as 90 days in 1.15, briefly 24 h). The embed session_token JWT was stated here as 300 s. (Corrected in 1.22: the shipped default is 7200 s — see the Revision 1.22 note.)
  • Idempotency clarified: deduplication is via an explicit Idempotency-Key header only (no automatic email+address dedup). Same key + different body → 409 idempotency.fingerprint_mismatch.
  • GET /partner/sessions/:id returns a SessionSummary projection, not a full payload echo.
  • Property response gains bin_collection (BinCollectionPayload) and a top-level broadband (BroadbandAvailability).
  • cache_status values are fresh | partial | throttled (the cached value is not emitted).
  • Signature image MIME types are image/png and image/jpeg (SVG is not accepted). Signature error code is signature_image.invalid only.
  • Error codes corrected to the shipped set: shape/consent failures → 400 validation.failed; bank modulus failures → 422 bank_details.invalid_modulus. Legacy codes bank_details.invalid_sort_code, bank_details.invalid_account_number, bank_details.modulus_check_failed, consent.required, auth.token_expired, auth.token_malformed, signature_image.too_large, signature_image.dimensions_invalid are not emitted by the current implementation.
  • error.type enumerated: invalid_request | validation_error | invalid_credentials | not_found | conflict | idempotency_conflict | rate_limited | discovery_in_progress | mode_not_implemented | internal. Session-creation schema failures return a single flattened message under validation.failed (no per-field param/missing_fields); business-rule failures at account creation use validation_error.
  • 503 mode.live_not_implemented documented for live_ requests to a method/path absent from the global live route allowlist. (Clarified in 1.22: gating is a single global allowlist, not per-partner.)
  • Theming split into Shipped surface (theme, disableAnimations, partial variables, mode, locale, gmapsKey, handle methods) and Roadmap (rules allowlist, full variable set, lock-list enforcement, WCAG 2.2 AA, full cy-GB translation).
  • InitOptions.embedOrigin documented (default https://embed-sandbox.hellobill.app; honored only in non-prod — ignored in production builds).
  • Schema reference updated: SignatureImage and LoaSignature types added; BinCollectionPayload, BroadbandAvailability added; CacheStatus removes 'cached'; CustomerCreateResponse adds loa_required.

Revision 1.15 — May 2026

Quality release. No request/response shape changes since 1.14. 22 internal fixes resolving inconsistencies, dangling cross-references, and missing type definitions.

  • Expanded §16 with all 12 webhook variants, ErrorCode union (32 codes), ErrorEnvelope, LoaRecord, LoaListResponse, CustomerStatusResponse, CustomerCreateResponse, SessionData, and other missing utility types
  • session.loa_signed webhook payload corrected to use signed_at_server (not signed_at)
  • Rate-limit code corrected to rate.limited throughout (was rate_limit.exceeded in some places)
  • Added GET /partner/sessions/:id dedicated section
  • Added §9.4 Events & Callbacks parent heading

Revision 1.14 — May 2026

Spec-only reconciliation. No API contract changes.

  • Bank-details field renames: direct_debit_guarantee_accepteddirect_debit_authorised, accepted_atauthorised_at, validated_at removed and replaced by status: 'collected' | 'pending' | 'failed' + collected_at
  • Webhook envelope: type documented as primary field; event as back-compat alias; livemode: boolean added
  • GMaps integration: InitOptions.integrations.gmapsKey added; embed falls back to inline SVG when absent
  • GET /partner/customers removed: endpoint returns 404; §8.7 stub added
  • CompletionResult: selected_products corrected from string[] to { product_id, location }[]
  • Property handler: HellobillHandlerSet table updated with property and bank-details routes

Revision 1.13 — May 2026

Additive minor release. No breaking changes for partners on v1.12.

  • New endpoint: POST /partner/sessions/:id/bank-details — optional UK bank account collection with BACS modulus checking
  • New endpoint: GET /partner/sessions/:id/bank-details — masked read-back
  • New webhook: bank_details.collected
  • New error codes: bank_details.invalid_sort_code, bank_details.invalid_account_number, bank_details.modulus_check_failed, bank_details.session_locked
  • Sandbox: bank details test vectors added (§15)

Revision 1.12 — May 2026

Additive at the API surface. Behavioural change: granular events moved from onEvent to onUserAction.

  • New callback: onUserAction — granular user-interaction events (service.selected, product.chosen, screen.entered, etc.)
  • Event taxonomy split: lifecycle events remain on onEvent (10 stable types); granular events route to onUserAction only
  • New §9.4.1: Event taxonomy documentation with comparison table
  • AppearanceObject: full theming API formalised in §16
  • Migration: integrations that consumed granular events via onEvent must add onUserAction callback